Skip to main content

netget/server/git/
actions.rs

1//! Git Smart HTTP protocol actions
2//!
3//! The model describes a repository as structured data - branch, commit message, and a list
4//! of `{path, content}` files - and the server compiles that into real Git objects
5//! (`super::pack`). It never asks the model for object IDs or pack bytes: SHA-1s are derived
6//! from the content, so the refs advertised by `GET /info/refs` and the objects in the pack
7//! returned by `POST /git-upload-pack` agree and `git clone` can actually complete.
8
9use crate::llm::actions::protocol_trait::{ActionResult, Protocol, Server};
10use crate::llm::actions::{ActionDefinition, Parameter, ParameterDefinition};
11use crate::protocol::log_template::LogTemplate;
12use crate::protocol::{EventType, SpawnContext};
13use crate::state::app_state::AppState;
14use anyhow::{anyhow, Result};
15use serde_json::{json, Value};
16use std::future::Future;
17use std::net::SocketAddr;
18use std::pin::Pin;
19use std::sync::LazyLock;
20
21/// Commit timestamp used when the action does not supply one.
22///
23/// Fixed, not "now": a clone is two HTTP requests, each answered separately, and the commit
24/// timestamp is part of the commit object. A wall-clock default would give the two requests
25/// different commit SHAs and the clone would fail with "did not send all necessary objects".
26pub const DEFAULT_COMMIT_TIMESTAMP: i64 = 1_700_000_000;
27
28/// Branch used when neither the action nor the `default_branch` startup parameter says.
29pub const FALLBACK_BRANCH: &str = "main";
30
31/// Git Smart HTTP protocol implementation
32#[derive(Clone)]
33pub struct GitProtocol {
34    _phantom: (),
35}
36
37impl GitProtocol {
38    /// Create a new Git protocol instance
39    pub fn new() -> Self {
40        Self { _phantom: () }
41    }
42}
43
44// Implement Protocol trait (common functionality)
45impl Protocol for GitProtocol {
46    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
47        vec![ParameterDefinition {
48            name: "default_branch".to_string(),
49            type_hint: "string".to_string(),
50            description:
51                "Branch name used when a git_repository action does not name one (default: main)"
52                    .to_string(),
53            required: false,
54            example: json!("main"),
55            default: None,
56        }]
57    }
58
59    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
60        // None. This protocol used to advertise create_git_repository / delete_git_repository /
61        // list_git_repositories; there is no repository store for them to act on (protocols
62        // must not implement storage), and their results were discarded, so they were three
63        // actions the model could call that did nothing at all. The repository is described
64        // per-request by git_repository instead.
65        Vec::new()
66    }
67
68    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
69        vec![git_repository_action(), git_error_action()]
70    }
71
72    fn protocol_name(&self) -> &'static str {
73        "Git"
74    }
75
76    fn get_event_types(&self) -> Vec<EventType> {
77        get_git_event_types()
78    }
79
80    fn stack_name(&self) -> &'static str {
81        "ETH>IP>TCP>HTTP>Git"
82    }
83
84    fn keywords(&self) -> Vec<&'static str> {
85        vec!["git", "git server", "via git"]
86    }
87
88    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
89        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
90
91        ProtocolMetadataV2::builder()
92            .state(DevelopmentState::Beta)
93            .implementation("Hand-rolled Git Smart HTTP v0 (pkt-line + pack v2) on hyper")
94            .llm_control("Branch, commit metadata and file contents; object IDs are computed")
95            .e2e_testing(
96                "The real `git` binary is the client: `git clone http://127.0.0.1:PORT/test-repo` \
97                 completes a Smart HTTP v0 fetch, then `git fsck --full` validates the pack we \
98                 sent, `git log -1 --format=%s` and `git show HEAD:README.md` assert the commit \
99                 subject and exact blob bytes, `git branch --show-current` asserts the branch, \
100                 and the checked-out tree is compared byte-for-byte including a nested path and \
101                 the executable bit (tests/server/git/e2e_test.rs, not #[ignore]d)",
102            )
103            .notes(
104                "Read-only (clone/fetch), single commit, no history, no push. A clone needs \
105                 the same snapshot from both the info/refs and the git-upload-pack event; a \
106                 static or script handler guarantees that, an LLM answering twice does not.",
107            )
108            .max_inbound_bytes(crate::server::git::MAX_UPLOAD_PACK_BYTES)
109            .request_only("Git smart HTTP is HTTP request/response; a server cannot send a peer anything unprompted")
110            .build()
111    }
112
113    fn description(&self) -> &'static str {
114        "Git Smart HTTP server for serving virtual repositories"
115    }
116
117    fn example_prompt(&self) -> &'static str {
118        "listen on port 9418 via git. Serve repository 'hello-world' on branch main with README.md containing '# Hello World'"
119    }
120
121    fn group_name(&self) -> &'static str {
122        "Web & File"
123    }
124
125    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
126        use crate::llm::actions::StartupExamples;
127
128        StartupExamples::new(
129            // LLM mode
130            json!({
131                "type": "open_server",
132                "port": 9418,
133                "base_stack": "git",
134                "instruction": "Git HTTP server. Serve repository 'hello-world' on branch main containing README.md with the text '# Hello World'. Answer git_info_refs and git_upload_pack with exactly the same git_repository action every time."
135            }),
136            // Script mode
137            json!({
138                "type": "open_server",
139                "port": 9418,
140                "base_stack": "git",
141                "event_handlers": [{
142                    "event_pattern": "*",
143                    "handler": {
144                        "type": "script",
145                        "language": "python",
146                        "code": "respond([{'type': 'git_repository', 'branch': 'main', 'files': [{'path': 'README.md', 'content': '# ' + event['repository'] + '\\n'}]}])"
147                    }
148                }]
149            }),
150            // Static mode - the deterministic path, and the one a clone is guaranteed to work on
151            json!({
152                "type": "open_server",
153                "port": 9418,
154                "base_stack": "git",
155                "event_handlers": [{
156                    // One handler for both git_info_refs and git_upload_pack: the same
157                    // snapshot must answer both requests of a clone.
158                    "event_pattern": "*",
159                    "handler": {
160                        "type": "static",
161                        "actions": [{
162                            "type": "git_repository",
163                            "branch": "main",
164                            "commit_message": "Initial commit",
165                            "files": [{"path": "README.md", "content": "# Hello World\n"}]
166                        }]
167                    }
168                }]
169            }),
170        )
171    }
172}
173
174// Implement Server trait (server-specific functionality)
175impl Server for GitProtocol {
176    fn spawn(&self, ctx: SpawnContext) -> Pin<Box<dyn Future<Output = Result<SocketAddr>> + Send>> {
177        Box::pin(async move {
178            let default_branch = ctx
179                .startup_params
180                .as_ref()
181                .map(|p| p.get_optional_string("default_branch"))
182                .transpose()?
183                .flatten()
184                .unwrap_or_else(|| FALLBACK_BRANCH.to_string());
185
186            crate::server::git::GitServer::spawn_with_llm_actions(
187                ctx.legacy_listen_addr(),
188                ctx.llm_client,
189                ctx.state,
190                ctx.status_tx,
191                default_branch,
192                ctx.server_id,
193            )
194            .await
195        })
196    }
197
198    fn execute_action(&self, action: Value) -> Result<ActionResult> {
199        let action_type = action
200            .get("type")
201            .and_then(|v| v.as_str())
202            .ok_or_else(|| anyhow!("Missing action type"))?;
203
204        match action_type {
205            "git_repository" => {
206                let files = action
207                    .get("files")
208                    .and_then(|v| v.as_array())
209                    .ok_or_else(|| {
210                        anyhow!("git_repository requires a 'files' array (it may be empty)")
211                    })?;
212
213                // Validate here so a bad path is reported as an action failure the model can
214                // see, rather than surfacing later as an opaque HTTP 500.
215                for file in files {
216                    let path = file
217                        .get("path")
218                        .and_then(|v| v.as_str())
219                        .ok_or_else(|| anyhow!("Every entry of 'files' needs a 'path' string"))?;
220                    if file.get("content").map(|c| !c.is_string()).unwrap_or(false) {
221                        return Err(anyhow!(
222                            "File {path:?}: 'content' must be a string (text only)"
223                        ));
224                    }
225                }
226
227                Ok(ActionResult::Custom {
228                    name: "git_repository_response".to_string(),
229                    data: json!({
230                        "branch": action.get("branch").and_then(|v| v.as_str()),
231                        "files": files,
232                        "commit_message": action.get("commit_message").and_then(|v| v.as_str()),
233                        "author_name": action.get("author_name").and_then(|v| v.as_str()),
234                        "author_email": action.get("author_email").and_then(|v| v.as_str()),
235                        "timestamp": action.get("timestamp").and_then(|v| v.as_i64()),
236                    }),
237                })
238            }
239            "git_error" => {
240                let message = action
241                    .get("message")
242                    .and_then(|v| v.as_str())
243                    .ok_or_else(|| anyhow!("Missing error message"))?;
244                let code = action.get("code").and_then(|v| v.as_u64()).unwrap_or(500);
245
246                Ok(ActionResult::Custom {
247                    name: "git_error_response".to_string(),
248                    data: json!({
249                        "message": message,
250                        "code": code
251                    }),
252                })
253            }
254            _ => Err(anyhow!("Unknown Git action: {}", action_type)),
255        }
256    }
257}
258
259fn git_repository_action() -> ActionDefinition {
260    ActionDefinition {
261        name: "git_repository".to_string(),
262        description:
263            "Describe the repository to serve. The server turns this into real Git objects and \
264             computes every SHA itself, so do NOT invent commit or blob hashes. Answer both \
265             git_info_refs and git_upload_pack with this action, and with the SAME content each \
266             time - a clone fetches refs and objects in two separate requests, and if the second \
267             answer differs from the first the client rejects the result."
268                .to_string(),
269        parameters: vec![
270            Parameter {
271                name: "files".to_string(),
272                type_hint: "array".to_string(),
273                description:
274                    "Files in the repository. Each entry: path (string, relative, no '..' or \
275                     '.git'), content (string, UTF-8 text), executable (boolean, optional). An \
276                     empty array serves a repository whose single commit has no files."
277                        .to_string(),
278                required: true,
279            },
280            Parameter {
281                name: "branch".to_string(),
282                type_hint: "string".to_string(),
283                description:
284                    "Branch name to publish, e.g. 'main' (default: the server's default_branch)"
285                        .to_string(),
286                required: false,
287            },
288            Parameter {
289                name: "commit_message".to_string(),
290                type_hint: "string".to_string(),
291                description: "Commit message (default: 'Initial commit')".to_string(),
292                required: false,
293            },
294            Parameter {
295                name: "author_name".to_string(),
296                type_hint: "string".to_string(),
297                description: "Commit author name (default: 'NetGet')".to_string(),
298                required: false,
299            },
300            Parameter {
301                name: "author_email".to_string(),
302                type_hint: "string".to_string(),
303                description: "Commit author email (default: 'netget@localhost')".to_string(),
304                required: false,
305            },
306            Parameter {
307                name: "timestamp".to_string(),
308                type_hint: "number".to_string(),
309                description:
310                    "Commit time as seconds since the Unix epoch. Defaults to a fixed value; \
311                     pass one only if you will pass the identical value on every request for \
312                     this repository, since it changes the commit hash."
313                        .to_string(),
314                required: false,
315            },
316        ],
317        example: json!({
318            "type": "git_repository",
319            "branch": "main",
320            "commit_message": "Initial commit",
321            "files": [
322                {"path": "README.md", "content": "# Hello World\n"},
323                {"path": "src/main.rs", "content": "fn main() { println!(\"hi\"); }\n"}
324            ]
325        }),
326        log_template: Some(
327            LogTemplate::new()
328                .with_info("-> git repo {branch} ({files_len} files)")
329                .with_debug("Git repository: branch={branch}, files={files_len}"),
330        ),
331    }
332}
333
334fn git_error_action() -> ActionDefinition {
335    ActionDefinition {
336        name: "git_error".to_string(),
337        description: "Refuse the request with an HTTP error (e.g. repository not found)"
338            .to_string(),
339        parameters: vec![
340            Parameter {
341                name: "message".to_string(),
342                type_hint: "string".to_string(),
343                description: "Error message shown to the git client".to_string(),
344                required: true,
345            },
346            Parameter {
347                name: "code".to_string(),
348                type_hint: "number".to_string(),
349                description: "HTTP status code (default: 500; use 404 for a missing repository)"
350                    .to_string(),
351                required: false,
352            },
353        ],
354        example: json!({
355            "type": "git_error",
356            "message": "Repository not found",
357            "code": 404
358        }),
359        log_template: Some(
360            LogTemplate::new()
361                .with_info("-> git error {code}: {message}")
362                .with_debug("Git error: code={code}, message={message}"),
363        ),
364    }
365}
366
367/// Reference discovery: `GET /<repo>/info/refs?service=git-upload-pack`.
368pub static GIT_INFO_REFS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
369    EventType::new(
370        "git_info_refs",
371        "Git client asked which branches a repository has (first request of a clone or fetch)",
372        json!({
373            "type": "git_repository",
374            "branch": "main",
375            "commit_message": "Initial commit",
376            "files": [{"path": "README.md", "content": "# Hello World\n"}]
377        }),
378    )
379    .with_parameters(vec![
380        Parameter {
381            name: "repository".to_string(),
382            type_hint: "string".to_string(),
383            description: "Repository name taken from the URL path".to_string(),
384            required: true,
385        },
386        Parameter {
387            name: "user_agent".to_string(),
388            type_hint: "string".to_string(),
389            description: "User-Agent header of the git client, if it sent one".to_string(),
390            required: false,
391        },
392        Parameter {
393            name: "client_ip".to_string(),
394            type_hint: "string".to_string(),
395            description: "Address of the connecting client".to_string(),
396            required: false,
397        },
398    ])
399    .with_actions(vec![git_repository_action(), git_error_action()])
400    .with_alternative_example(json!({
401        "type": "git_error",
402        "message": "Repository not found",
403        "code": 404
404    }))
405    .with_log_template(
406        LogTemplate::new()
407            .with_info("{client_ip} git info/refs {repository}")
408            .with_debug("Git info/refs: repository={repository}, agent={user_agent}")
409            .with_trace("Git info/refs: {json_pretty(.)}"),
410    )
411});
412
413/// Object transfer: `POST /<repo>/git-upload-pack`.
414pub static GIT_UPLOAD_PACK_EVENT: LazyLock<EventType> = LazyLock::new(|| {
415    EventType::new(
416        "git_upload_pack",
417        "Git client asked for the objects of a repository (second request of a clone or fetch)",
418        json!({
419            "type": "git_repository",
420            "branch": "main",
421            "commit_message": "Initial commit",
422            "files": [{"path": "README.md", "content": "# Hello World\n"}]
423        }),
424    )
425    .with_parameters(vec![
426        Parameter {
427            name: "repository".to_string(),
428            type_hint: "string".to_string(),
429            description: "Repository name taken from the URL path".to_string(),
430            required: true,
431        },
432        Parameter {
433            name: "wants".to_string(),
434            type_hint: "array".to_string(),
435            description: "Object IDs the client asked for, as advertised by git_info_refs"
436                .to_string(),
437            required: false,
438        },
439        Parameter {
440            name: "haves".to_string(),
441            type_hint: "array".to_string(),
442            description: "Object IDs the client already has (empty for a fresh clone)".to_string(),
443            required: false,
444        },
445        Parameter {
446            name: "capabilities".to_string(),
447            type_hint: "array".to_string(),
448            description: "Protocol capabilities the client selected".to_string(),
449            required: false,
450        },
451        Parameter {
452            name: "client_ip".to_string(),
453            type_hint: "string".to_string(),
454            description: "Address of the connecting client".to_string(),
455            required: false,
456        },
457    ])
458    .with_actions(vec![git_repository_action(), git_error_action()])
459    .with_alternative_example(json!({
460        "type": "git_error",
461        "message": "Repository not found",
462        "code": 404
463    }))
464    .with_log_template(
465        LogTemplate::new()
466            .with_info("{client_ip} git upload-pack {repository}")
467            .with_debug("Git upload-pack: repository={repository}, wants={wants}")
468            .with_trace("Git upload-pack: {json_pretty(.)}"),
469    )
470});
471
472pub fn get_git_event_types() -> Vec<EventType> {
473    vec![GIT_INFO_REFS_EVENT.clone(), GIT_UPLOAD_PACK_EVENT.clone()]
474}