Skip to main content

netget/client/git/
actions.rs

1//! Git client protocol actions implementation
2
3use 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
15/// Git client connected event
16pub 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
32/// Raised after every Git verb that ran, carrying what it produced.
33///
34/// This and [`GIT_OPERATION_ERROR_EVENT`] used to be declared inside `get_event_types()` as
35/// throwaway `EventType`s with a `{"type": "placeholder"}` example, and **nothing raised
36/// either of them**. The model therefore got exactly one turn — the connected event — and
37/// then went deaf: it could ask for a log and never see one. `CLAUDE.md` records this shape
38/// ("event declared and never emitted") as one of the three ways a client throws the model's
39/// answer away.
40pub 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
71/// Raised when a Git verb failed, carrying the reason.
72pub 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
94/// Git client protocol action handler
95pub struct GitClientProtocol;
96
97impl GitClientProtocol {
98    pub fn new() -> Self {
99        Self
100    }
101}
102
103// Implement Protocol trait (common functionality)
104impl Protocol for GitClientProtocol {
105    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
106        vec![
107            ParameterDefinition {
108                name: "local_path".to_string(),
109                // Read by `GitClient::connect_with_llm_actions`: it seeds the session when it
110                // names a repository that already exists, and is the default destination for
111                // a `git_clone` that omits `path`. It was declared and read by nothing at all
112                // — an advertised knob that did nothing when turned — while all three startup
113                // examples told the model to set it.
114                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                // Read by `GitClient::connect_with_llm_actions`, which builds a
126                // `sandbox::GitSandbox` from it before any path is accepted. Every
127                // model-supplied path -- `local_path`, `remote_addr`, `git_clone`'s `path`
128                // and a local `git_clone` `url` -- is canonicalised and refused if it lands
129                // outside. See `src/client/git/sandbox.rs`.
130                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                // Read by `GitClient::connect_with_llm_actions` and enforced in
147                // `run_git_operation` via `GitSandbox::require_remote_writes`.
148                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            // Git client operations are typically async (user-initiated)
450            // Sync actions would be for responding to events, but Git doesn't have
451            // server-initiated events like TCP does
452        ]
453    }
454    fn protocol_name(&self) -> &'static str {
455        "Git"
456    }
457    fn get_event_types(&self) -> Vec<EventType> {
458        // The statics the client actually raises. This used to build three throwaway
459        // `EventType`s whose example action was `{"type": "placeholder"}` — a shape
460        // `execute_action` rejects, rendered verbatim into the documentation the model
461        // reads — and whose `git_connected` was a *different* value from the one
462        // `GitClient::connect_with_llm_actions` raises.
463        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            // LLM mode: LLM controls Git operations
511            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            // Script mode: Code-based Git operations
521            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            // Static mode: Fixed git status check
538            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
570// Implement Client trait (client-specific functionality)
571impl 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                // The declared startup parameters have to be handed over explicitly.
587                // `ClientInstance::protocol_data` — which `read_field` consults — is left
588                // `Value::Null` by `client_startup.rs` and is only ever populated by a
589                // client that writes to it itself, so a parameter read *only* from there
590                // is never actually delivered. `ctx.startup_params` is the channel that
591                // carries what the caller passed.
592                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}