1use 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
21pub const DEFAULT_COMMIT_TIMESTAMP: i64 = 1_700_000_000;
27
28pub const FALLBACK_BRANCH: &str = "main";
30
31#[derive(Clone)]
33pub struct GitProtocol {
34 _phantom: (),
35}
36
37impl GitProtocol {
38 pub fn new() -> Self {
40 Self { _phantom: () }
41 }
42}
43
44impl 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 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 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 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 json!({
152 "type": "open_server",
153 "port": 9418,
154 "base_stack": "git",
155 "event_handlers": [{
156 "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
174impl 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 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
367pub 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
413pub 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}