Skip to main content

netget/client/git/
mod.rs

1//! Git client implementation
2pub mod actions;
3pub mod credentials;
4pub mod sandbox;
5
6pub use actions::GitClientProtocol;
7pub use sandbox::{GitSandbox, ALLOWED_ROOT_PARAM, ALLOW_REMOTE_WRITES_PARAM};
8
9use crate::protocol::StartupParams;
10use anyhow::{Context, Result};
11use git2::{BranchType, ObjectType, Repository, StatusOptions};
12use std::net::SocketAddr;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use tokio::sync::{mpsc, Mutex};
16use tracing::{debug, error, info, warn};
17
18use crate::client::git::actions::{
19    GIT_CLIENT_CONNECTED_EVENT, GIT_OPERATION_COMPLETED_EVENT, GIT_OPERATION_ERROR_EVENT,
20};
21use crate::client::llm_budget::call_llm_for_client;
22use crate::llm::actions::client_trait::{Client, ClientActionResult};
23use crate::llm::ollama_client::OllamaClient;
24use crate::llm::ClientLlmResult;
25use crate::protocol::Event;
26use crate::state::app_state::AppState;
27use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
28use crate::state::{AccessLogOwner, ClientId, ClientStatus};
29
30/// Everything one Git client session carries between actions.
31///
32/// This used to be three `&mut Option<...>` locals owned by the connect task, which is
33/// why nothing outside that task could run a Git action: the working repository was
34/// unreachable. Behind an `Arc<Mutex<_>>` the LLM path and the injected-command loop
35/// share one session, so `[ send ]` operates on the repository the LLM just cloned.
36struct GitSession {
37    repo_path: Option<PathBuf>,
38    /// The `local_path` startup parameter, used as the clone destination when a
39    /// `git_clone` action does not name one. Already resolved inside the sandbox.
40    local_path: Option<String>,
41    /// The declared `username`/`password`, bound to the one origin they may be offered to.
42    /// See [`credentials`].
43    credentials: Option<credentials::CredentialScope>,
44    /// The filesystem boundary every model-supplied path is checked against. See
45    /// [`sandbox`]. Held here rather than rebuilt per operation so the root is
46    /// canonicalised (and created) exactly once, at connect.
47    sandbox: GitSandbox,
48}
49
50/// Read one string startup parameter.
51///
52/// Two sources, in order, because the client has two and only one of them is reliably
53/// populated. `ConnectContext::startup_params` carries what the caller actually passed;
54/// `ClientInstance::protocol_data` is left `Value::Null` by `cli/client_startup.rs` and is
55/// written only by clients that write to it themselves — which this one does not. A
56/// parameter read *only* from `protocol_data` is therefore never delivered, whatever the
57/// declaration says, and that was the state of `local_path`, `username` and `password`. The
58/// `protocol_data` lookup is kept as a second chance in case something populates it later.
59async fn read_field(
60    params: Option<&StartupParams>,
61    app_state: &AppState,
62    client_id: ClientId,
63    key: &str,
64) -> Option<String> {
65    if let Some(params) = params {
66        if let Ok(Some(value)) = params.get_optional_string(key) {
67            return Some(value);
68        }
69    }
70    app_state
71        .with_client_mut(client_id, |client| {
72            client
73                .get_protocol_field(key)
74                .and_then(|v| v.as_str())
75                .map(|s| s.to_string())
76        })
77        .await
78        .flatten()
79}
80
81/// Read one boolean startup parameter, accepting the string forms a model is apt to send.
82///
83/// `"true"` and `true` are the same intent, and a parameter that silently reads as `false`
84/// because the model quoted it is the "declared but does nothing when turned" trap. Anything
85/// unrecognised is `false` — this gates a destructive capability, so an unparseable value
86/// must not open it.
87async fn read_bool_field(
88    params: Option<&StartupParams>,
89    app_state: &AppState,
90    client_id: ClientId,
91    key: &str,
92) -> bool {
93    fn truthy(value: &serde_json::Value) -> bool {
94        match value {
95            serde_json::Value::Bool(b) => *b,
96            serde_json::Value::String(s) => matches!(
97                s.trim().to_ascii_lowercase().as_str(),
98                "true" | "yes" | "1" | "on"
99            ),
100            _ => false,
101        }
102    }
103
104    if let Some(params) = params {
105        // `get_optional_bool` rejects the quoted form outright, and a model that sends
106        // `"false"` must not be read as an unparseable value that then falls through to a
107        // second source. So the raw value is consulted when the typed accessor declines.
108        if let Ok(Some(b)) = params.get_optional_bool(key) {
109            return b;
110        }
111        if let Ok(Some(raw)) = params.get_optional_string(key) {
112            return truthy(&serde_json::Value::String(raw));
113        }
114    }
115    app_state
116        .with_client_mut(client_id, |client| {
117            client.get_protocol_field(key).map(truthy)
118        })
119        .await
120        .flatten()
121        .unwrap_or(false)
122}
123
124/// What one executed action did. Shared vocabulary between the connected-event handler
125/// and the injected-command loop.
126enum Applied {
127    /// The action ran. `detail` is the one-line summary the dashboard shows; `output` is
128    /// what the operation produced (log text, diff, branch list) for the model to read in
129    /// the `git_operation_completed` event.
130    Ran {
131        detail: String,
132        output: Option<String>,
133    },
134    /// The action asked to end the session.
135    Disconnect,
136}
137
138impl Applied {
139    /// A result with nothing for the model to read beyond the summary.
140    fn ran(detail: impl Into<String>) -> Self {
141        Applied::Ran {
142            detail: detail.into(),
143            output: None,
144        }
145    }
146}
147
148/// How many times an operation's result may be answered by another operation before this
149/// client stops reporting. A model may reasonably chain clone -> log -> checkout; without a
150/// bound, one that answers `git_status` with `git_status` would loop on the LLM forever.
151const MAX_FOLLOWUP_DEPTH: u8 = 6;
152
153/// The largest operation output handed to the model, in bytes. A diff or a log can be
154/// arbitrarily long and the whole thing would crowd out the rest of the prompt.
155const MAX_OUTPUT_BYTES_FOR_MODEL: usize = 8000;
156
157/// Git client that performs Git operations
158pub struct GitClient;
159
160impl GitClient {
161    /// Connect (initialize) a Git client with integrated LLM actions
162    pub async fn connect_with_llm_actions(
163        remote_addr: String,
164        llm_client: OllamaClient,
165        app_state: Arc<AppState>,
166        status_tx: mpsc::UnboundedSender<String>,
167        client_id: ClientId,
168        startup_params: Option<StartupParams>,
169    ) -> Result<SocketAddr> {
170        let params = startup_params.as_ref();
171        // For Git, remote_addr can be either:
172        // 1. A repository URL (for cloning)
173        // 2. A local path (for existing repo)
174        // We'll determine this based on the instruction
175
176        info!(
177            "Git client {} initializing with target: {}",
178            client_id, remote_addr
179        );
180
181        // Update client state
182        app_state
183            .update_client_status(client_id, ClientStatus::Connected)
184            .await;
185        let _ = status_tx.send(format!("[CLIENT] Git client {} initialized", client_id));
186        let _ = status_tx.send("__UPDATE_UI__".to_string());
187
188        // Create a dummy socket address since Git doesn't use network sockets
189        // We use a placeholder address to satisfy the return type
190        let dummy_addr: SocketAddr = "127.0.0.1:0".parse()?;
191
192        // Get initial instruction
193        let instruction = app_state
194            .get_instruction_for_client(client_id)
195            .await
196            .unwrap_or_default();
197
198        // Build the filesystem boundary before anything is allowed to name a path. Failing
199        // here fails the connect: a Git client whose workspace could not be established has
200        // nowhere safe to work, and starting it anyway would mean the first `git_clone`
201        // decides where NetGet writes.
202        let sandbox = GitSandbox::new(
203            read_field(params, &app_state, client_id, sandbox::ALLOWED_ROOT_PARAM)
204                .await
205                .as_deref(),
206            read_bool_field(
207                params,
208                &app_state,
209                client_id,
210                sandbox::ALLOW_REMOTE_WRITES_PARAM,
211            )
212            .await,
213        )?;
214        info!(
215            "Git client {} confined to {} (remote writes {})",
216            client_id,
217            sandbox.root().display(),
218            if sandbox.remote_writes_allowed() {
219                "permitted"
220            } else {
221                "refused"
222            }
223        );
224
225        // Seed the session from `remote_addr` when it already names a repository on
226        // disk. Without this, every verb other than `git_clone` was a no-op on a
227        // freshly created client - `repo_path` started `None` and only a clone could
228        // ever set it - even though the documented contract is that `remote_addr` may
229        // be "a local path (for existing repo)".
230        //
231        // `local_path` is tried the same way, and for the same reason: all three startup
232        // examples tell the model to set it, and until now nothing in the client read it.
233        //
234        // Both are now confined. The two are treated differently on purpose:
235        //
236        //  * `local_path` is unambiguously a path, and it is also the default clone
237        //    destination, so an out-of-root value is refused *here* rather than at the
238        //    first clone. Failing at startup names the parameter while the operator is
239        //    still looking at it.
240        //  * `remote_addr` may legitimately be a clone URL, and refusing every URL that
241        //    does not resolve inside the root would break the ordinary case. So it is
242        //    refused only when it names a *real directory on this disk* outside the root —
243        //    which is exactly the case the guard exists for, and which no URL can be. A
244        //    non-existent value simply does not seed a repository, as before.
245        let local_path = match read_field(params, &app_state, client_id, "local_path").await {
246            Some(raw) => Some(
247                sandbox
248                    .resolve(&raw, "the `local_path` startup parameter")?
249                    .to_string_lossy()
250                    .into_owned(),
251            ),
252            None => None,
253        };
254
255        if Path::new(&remote_addr).is_dir() && sandbox.resolve(&remote_addr, "remote_addr").is_err()
256        {
257            let err = sandbox.resolve(&remote_addr, "remote_addr").unwrap_err();
258            warn!(
259                "Git client {} refused: remote_addr names a directory outside the sandbox",
260                client_id
261            );
262            return Err(err);
263        }
264
265        let repo_path = [Some(remote_addr.clone()), local_path.clone()]
266            .into_iter()
267            .flatten()
268            // Confine before opening. `Repository::open` walks *up* from the path it is
269            // given looking for a `.git`, so an unconfined candidate one level outside
270            // the root could open an enclosing repository that is entirely outside it.
271            .filter_map(|candidate| sandbox.resolve(&candidate, "repository path").ok())
272            .find_map(|candidate| match Repository::open(&candidate) {
273                Ok(repo) => {
274                    let path = repo
275                        .workdir()
276                        .map(|w| w.to_path_buf())
277                        .unwrap_or_else(|| candidate.clone());
278                    // `open` walked upwards to find this, so re-check the result: the
279                    // repository it landed on may sit above the root even though the
280                    // path handed in did not.
281                    match sandbox.resolve(&path.to_string_lossy(), "opened repository") {
282                        Ok(confined) => {
283                            info!(
284                                "Git client {} opened existing repository at {}",
285                                client_id,
286                                confined.display()
287                            );
288                            Some(confined)
289                        }
290                        Err(e) => {
291                            warn!("Git client {} ignoring repository: {}", client_id, e);
292                            None
293                        }
294                    }
295                }
296                Err(_) => None,
297            });
298
299        // Seed the declared credentials. They were plumbed all the way to
300        // `Cred::userpass_plaintext` but the session was built with
301        // `..Default::default()`, so both were always `None` and authenticated
302        // Git over HTTPS could never work -- a parameter declared, threaded and
303        // never actually supplied. CLAUDE.md calls a declared-but-unread
304        // parameter dead weight the model will try to use; this was the shape
305        // where the plumbing existed and only the seeding was missing.
306        //
307        // They are bound to one origin: `remote_addr`'s when it is a URL, else the
308        // opened repository's `origin` remote. A credential that cannot be bound to a
309        // host is offered nowhere — see `credentials`.
310        let username = read_field(params, &app_state, client_id, "username").await;
311        let password = read_field(params, &app_state, client_id, "password").await;
312        let bound_to = credentials::remote_origin(&remote_addr)
313            .map(|_| remote_addr.clone())
314            .or_else(|| {
315                repo_path.as_ref().and_then(|path| {
316                    Repository::open(path)
317                        .ok()?
318                        .find_remote("origin")
319                        .ok()?
320                        .url()
321                        .map(str::to_owned)
322                })
323            });
324        let credentials = credentials::CredentialScope::bind(
325            username.as_deref(),
326            password.as_deref(),
327            bound_to.as_deref(),
328        );
329        match (&credentials, username.is_some() && password.is_some()) {
330            (Some(scope), _) => info!(
331                "Git client {} credentials are bound to {}",
332                client_id, scope.origin
333            ),
334            (None, true) => warn!(
335                "Git client {} has a username/password but neither remote_addr nor an \
336                 opened repository's `origin` names a host, so they will be offered to no one",
337                client_id
338            ),
339            (None, false) => {}
340        }
341
342        let session = Arc::new(Mutex::new(GitSession {
343            repo_path,
344            local_path,
345            credentials,
346            sandbox,
347        }));
348
349        // Command channel for injected actions (the dashboard's [ send ] / composer).
350        // Registered BEFORE the connected-event LLM call, which a manual `*` routing rule
351        // can park for minutes - the operator must be able to run a Git operation while it
352        // waits.
353        let command_rx =
354            crate::client::command_support::register_command_channel(&app_state, client_id).await;
355        let cmd_task = tokio::spawn(Self::command_loop(
356            command_rx,
357            client_id,
358            session.clone(),
359            app_state.clone(),
360            llm_client.clone(),
361            status_tx.clone(),
362        ));
363        app_state.register_client_task(client_id, cmd_task).await;
364
365        // Spawn task to handle LLM-driven Git operations
366        // Registered with AppState so stop_client can abort this task —
367        // dropping a JoinHandle only detaches it in Tokio.
368        let task_registrar = app_state.clone();
369        let llm_session = session.clone();
370        let task_handle = tokio::spawn(async move {
371            let protocol = Arc::new(GitClientProtocol::new());
372
373            // Send connected event
374            let event = Event::new(
375                &GIT_CLIENT_CONNECTED_EVENT,
376                serde_json::json!({
377                    "repository_path": remote_addr,
378                }),
379            );
380
381            let memory = app_state
382                .get_memory_for_client(client_id)
383                .await
384                .unwrap_or_default();
385
386            // Initial LLM call
387            match call_llm_for_client(
388                &llm_client,
389                &app_state,
390                client_id.to_string(),
391                &instruction,
392                &memory,
393                Some(&event),
394                protocol.as_ref(),
395                &status_tx,
396            )
397            .await
398            {
399                Ok(ClientLlmResult {
400                    actions,
401                    memory_updates,
402                }) => {
403                    // Update memory
404                    if let Some(mem) = memory_updates {
405                        app_state.set_memory_for_client(client_id, mem).await;
406                    }
407
408                    // Execute initial actions through the same path injected commands
409                    // use, so the git2 dispatch exists exactly once. Each result is
410                    // reported back to the model as a git_operation_completed /
411                    // git_operation_error event, so a clone can be followed by a log and
412                    // the log's text actually reaches the model.
413                    for action in actions {
414                        if Self::run_and_report(
415                            action,
416                            &protocol,
417                            &llm_session,
418                            client_id,
419                            &llm_client,
420                            &app_state,
421                            &status_tx,
422                            0,
423                        )
424                        .await
425                        {
426                            break;
427                        }
428                    }
429                }
430                Err(e) => {
431                    error!("LLM error for Git client {}: {}", client_id, e);
432                    app_state
433                        .update_client_status(client_id, ClientStatus::Error(e.to_string()))
434                        .await;
435                    let _ = status_tx.send("__UPDATE_UI__".to_string());
436                }
437            }
438
439            // Git client doesn't have a persistent connection, so we just mark it as done
440            debug!("Git client {} operations completed", client_id);
441        });
442        task_registrar
443            .register_client_task(client_id, task_handle)
444            .await;
445
446        Ok(dummy_addr)
447    }
448
449    /// Drain injected commands until the channel closes (the client was removed) or an
450    /// injected `disconnect` ends the session.
451    ///
452    /// `command_support::handle_stream_client_command` cannot serve this client: Git owns
453    /// no socket NetGet can write to, and every Git verb yields
454    /// `ClientActionResult::Custom`. So the action goes through
455    /// [`Self::execute_git_action`], the same function the connected-event path uses.
456    async fn command_loop(
457        mut command_rx: mpsc::Receiver<ClientCommand>,
458        client_id: ClientId,
459        session: Arc<Mutex<GitSession>>,
460        app_state: Arc<AppState>,
461        llm_client: OllamaClient,
462        status_tx: mpsc::UnboundedSender<String>,
463    ) {
464        use crate::llm::actions::protocol_trait::Protocol;
465
466        let protocol = Arc::new(GitClientProtocol::new());
467
468        while let Some(command) = command_rx.recv().await {
469            let action = command.action.clone();
470            let outcome = match Self::execute_git_action(
471                &action,
472                &protocol,
473                &session,
474                client_id,
475                &llm_client,
476                &app_state,
477                &status_tx,
478            )
479            .await
480            {
481                // Never `Sent`: git2 talks to the repository (and, for clone/fetch/pull/
482                // push, to the remote) over sockets it owns and never reports byte
483                // counts for, so a number here would be invented. `Executed` carries
484                // what the operation actually produced instead.
485                Ok(Applied::Ran { detail, .. }) => Ok(ClientSendOutcome::Executed { detail }),
486                Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
487                // `execute_git_action` returns `Err` both for an action the protocol
488                // rejects and for a git2 operation that failed. Only the first is
489                // `Rejected`; the caller sees the git2 failure as an error, not as a
490                // silent success.
491                Err(e) => {
492                    if e.downcast_ref::<RejectedAction>().is_some() {
493                        Ok(ClientSendOutcome::Rejected {
494                            error: e.to_string(),
495                        })
496                    } else {
497                        Err(e)
498                    }
499                }
500            };
501
502            let outcome_json = match &outcome {
503                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
504                Err(e) => serde_json::json!({"error": e.to_string()}),
505            };
506            app_state
507                .record_access_log(
508                    AccessLogOwner::Client(client_id.as_u32()),
509                    protocol.protocol_name(),
510                    None,
511                    "injected_action",
512                    action,
513                    vec![outcome_json],
514                )
515                .await;
516
517            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
518            if let Err(e) = &outcome {
519                error!("Git client {} injected action failed: {}", client_id, e);
520                let _ = status_tx.send(format!(
521                    "[WARN] Client {} injected action failed: {}",
522                    client_id, e
523                ));
524            }
525            let _ = status_tx.send("__UPDATE_UI__".to_string());
526            crate::client::command_support::reply(command, outcome);
527
528            if disconnect {
529                break;
530            }
531        }
532
533        // Nothing can be injected any more: stop the dashboard offering [ send ].
534        app_state.remove_client_handle(client_id).await;
535        let _ = status_tx.send("__UPDATE_UI__".to_string());
536        info!("Git client {} command loop ended", client_id);
537    }
538
539    /// Run one action and report what it produced back to the model, then run whatever the
540    /// model answers with. Returns `true` when the session ended.
541    ///
542    /// Boxed because it is genuinely self-referential: an action raises an event, the event
543    /// is answered with more actions, and those come back here. An `async fn` that awaits
544    /// itself has an infinitely-sized future (E0391), and `+ Send` has to be named because
545    /// this is awaited inside a `tokio::spawn`.
546    #[allow(clippy::too_many_arguments)]
547    fn run_and_report<'a>(
548        action: serde_json::Value,
549        protocol: &'a Arc<GitClientProtocol>,
550        session: &'a Arc<Mutex<GitSession>>,
551        client_id: ClientId,
552        llm_client: &'a OllamaClient,
553        app_state: &'a Arc<AppState>,
554        status_tx: &'a mpsc::UnboundedSender<String>,
555        depth: u8,
556    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + 'a>> {
557        Box::pin(async move {
558            let operation = action
559                .get("type")
560                .and_then(|v| v.as_str())
561                .unwrap_or("unknown")
562                .to_string();
563
564            let event = match Self::execute_git_action(
565                &action, protocol, session, client_id, llm_client, app_state, status_tx,
566            )
567            .await
568            {
569                Ok(Applied::Ran { detail, output }) => {
570                    info!("Git client {}: {}", client_id, detail);
571                    let mut data = serde_json::json!({
572                        "operation": operation,
573                        "detail": detail,
574                    });
575                    if let Some(output) = output {
576                        data["output"] = serde_json::Value::String(crate::utils::truncate_for_llm(
577                            &output,
578                            MAX_OUTPUT_BYTES_FOR_MODEL,
579                        ));
580                    }
581                    Event::new(&GIT_OPERATION_COMPLETED_EVENT, data)
582                }
583                Ok(Applied::Disconnect) => return true,
584                Err(e) => {
585                    error!("Git client {} action error: {}", client_id, e);
586                    let _ =
587                        status_tx.send(format!("[CLIENT] Git client {} error: {}", client_id, e));
588                    Event::new(
589                        &GIT_OPERATION_ERROR_EVENT,
590                        serde_json::json!({
591                            "operation": operation,
592                            "error": e.to_string(),
593                        }),
594                    )
595                }
596            };
597
598            if depth >= MAX_FOLLOWUP_DEPTH {
599                warn!(
600                    "Git client {} reached the follow-up depth limit ({}); not reporting {} \
601                     to the model",
602                    client_id, MAX_FOLLOWUP_DEPTH, event.event_type.id
603                );
604                return false;
605            }
606
607            let instruction = app_state
608                .get_instruction_for_client(client_id)
609                .await
610                .unwrap_or_default();
611            let memory = app_state
612                .get_memory_for_client(client_id)
613                .await
614                .unwrap_or_default();
615
616            match call_llm_for_client(
617                llm_client,
618                app_state,
619                client_id.to_string(),
620                &instruction,
621                &memory,
622                Some(&event),
623                protocol.as_ref(),
624                status_tx,
625            )
626            .await
627            {
628                Ok(ClientLlmResult {
629                    actions,
630                    memory_updates,
631                }) => {
632                    if let Some(mem) = memory_updates {
633                        app_state.set_memory_for_client(client_id, mem).await;
634                    }
635                    for action in actions {
636                        if Self::run_and_report(
637                            action,
638                            protocol,
639                            session,
640                            client_id,
641                            llm_client,
642                            app_state,
643                            status_tx,
644                            depth + 1,
645                        )
646                        .await
647                        {
648                            return true;
649                        }
650                    }
651                }
652                Err(e) => {
653                    // Nothing is written to a wire here: git2 owns any socket and no peer is
654                    // waiting on us, so silence costs nothing but the log must say why.
655                    error!(
656                        "LLM error for Git client {} on {}: {}",
657                        client_id, event.event_type.id, e
658                    );
659                }
660            }
661
662            false
663        })
664    }
665
666    /// Execute a Git action based on LLM decision (or on an injected command).
667    ///
668    /// Every git2 call is blocking - `Repository::clone` and `remote.push` do real
669    /// network I/O - so the whole dispatch runs on `spawn_blocking` rather than on the
670    /// async runtime's worker.
671    async fn execute_git_action(
672        action: &serde_json::Value,
673        protocol: &Arc<GitClientProtocol>,
674        session: &Arc<Mutex<GitSession>>,
675        client_id: ClientId,
676        _llm_client: &OllamaClient,
677        app_state: &Arc<AppState>,
678        status_tx: &mpsc::UnboundedSender<String>,
679    ) -> Result<Applied> {
680        let (name, data) = match protocol
681            .execute_action(action.clone())
682            .map_err(|e| anyhow::Error::new(RejectedAction(e.to_string())))?
683        {
684            ClientActionResult::Custom { name, data } => (name, data),
685            ClientActionResult::Disconnect => {
686                info!("Git client {} disconnecting", client_id);
687                app_state
688                    .update_client_status(client_id, ClientStatus::Disconnected)
689                    .await;
690                let _ = status_tx.send("__UPDATE_UI__".to_string());
691                return Ok(Applied::Disconnect);
692            }
693            ClientActionResult::WaitForMore => return Ok(Applied::ran("wait_for_more")),
694            ClientActionResult::NoAction => return Ok(Applied::ran("no_action")),
695            ClientActionResult::SendData(_) => {
696                return Ok(Applied::ran(
697                    "send_data has no meaning for a Git client (git2 owns any socket)",
698                ))
699            }
700            ClientActionResult::Multiple(_) => {
701                return Ok(Applied::ran(
702                    "multiple results are not produced by the Git client",
703                ))
704            }
705        };
706
707        // Copy the session out; git2 is synchronous, so nothing awaits while we hold it.
708        let (repo_path, local_path, credentials, sandbox) = {
709            let guard = session.lock().await;
710            (
711                guard.repo_path.clone(),
712                guard.local_path.clone(),
713                guard.credentials.clone(),
714                guard.sandbox.clone(),
715            )
716        };
717
718        let op_status_tx = status_tx.clone();
719        let op_name = name.clone();
720        let outcome = tokio::task::spawn_blocking(move || {
721            Self::run_git_operation(
722                &op_name,
723                &data,
724                repo_path,
725                local_path.as_deref(),
726                credentials.as_ref(),
727                &sandbox,
728                client_id,
729                &op_status_tx,
730            )
731        })
732        .await
733        .context("Git operation task panicked")??;
734
735        if let Some(path) = outcome.repo_path {
736            session.lock().await.repo_path = Some(path);
737        }
738
739        Ok(Applied::Ran {
740            detail: outcome.detail,
741            output: outcome.output,
742        })
743    }
744
745    /// The blocking half of [`Self::execute_git_action`]: one git2 operation.
746    #[allow(clippy::too_many_arguments)]
747    fn run_git_operation(
748        name: &str,
749        data: &serde_json::Value,
750        repo_path: Option<PathBuf>,
751        local_path: Option<&str>,
752        credentials: Option<&credentials::CredentialScope>,
753        sandbox: &GitSandbox,
754        client_id: ClientId,
755        status_tx: &mpsc::UnboundedSender<String>,
756    ) -> Result<OperationOutcome> {
757        // Every verb but `git_clone` needs an open repository. This used to be a silent
758        // `if let Some(..)` that did nothing when no repository was open, so a model (or
759        // an operator) got success-shaped silence for an operation that never ran.
760        //
761        // The confinement check is repeated here rather than trusted from the two places
762        // that can set `repo_path` (connect-time seeding and `git_clone`). It costs one
763        // `canonicalize` per operation and makes the property local to this function: every
764        // verb below is confined because *it* checks, not because of an argument about what
765        // could have reached the session field. It also catches the case the entry-point
766        // checks structurally cannot — a workspace repository that was moved, or replaced
767        // by a symlink, after it was opened.
768        let require_repo = || -> Result<PathBuf> {
769            let path = repo_path.as_ref().ok_or_else(|| {
770                anyhow::anyhow!(
771                    "{name} needs an open repository: this client has none (clone one with \
772                     git_clone, or point remote_addr at a local repository)"
773                )
774            })?;
775            sandbox.resolve(
776                &path.to_string_lossy(),
777                &format!("the repository {name} would act on"),
778            )
779        };
780
781        match name {
782            "git_clone" => {
783                let url = data
784                    .get("url")
785                    .and_then(|v| v.as_str())
786                    .context("Missing url")?;
787                // `path` falls back to the client's `local_path` startup parameter, which is
788                // what every startup example sets and what nothing used to read.
789                let requested_path = data
790                    .get("path")
791                    .and_then(|v| v.as_str())
792                    .or(local_path)
793                    .context(
794                        "git_clone needs a 'path' to clone into, or a 'local_path' startup \
795                         parameter on the client",
796                    )?;
797
798                // The destination is where this client writes, so it is confined. A
799                // relative path lands inside the root; an absolute one outside it is
800                // refused, not quietly moved (see `sandbox`).
801                let destination = sandbox.resolve(requested_path, "the git_clone 'path'")?;
802
803                // The *source* is confined too when it is local. A `file://` or bare-path
804                // clone reads an arbitrary repository, and its contents then sit in the
805                // workspace where a permitted git_push could publish them — so pointing a
806                // clone at the operator's private work is an exfiltration step, not a
807                // read-only convenience. A network URL is not a filesystem concern and is
808                // left alone.
809                let effective_url = match sandbox::classify_clone_source(url) {
810                    sandbox::CloneSource::Remote => url.to_string(),
811                    sandbox::CloneSource::Local(local) => sandbox
812                        .resolve(
813                            &local,
814                            "the git_clone 'url', which names a local repository",
815                        )?
816                        .to_string_lossy()
817                        .into_owned(),
818                };
819
820                let shown = destination.display().to_string();
821                info!("Git client {} cloning {} to {}", client_id, url, shown);
822                let _ = status_tx.send(format!(
823                    "[CLIENT] Git client {} cloning {} to {}",
824                    client_id, url, shown
825                ));
826
827                Self::git_clone(&effective_url, &shown, credentials)
828                    .with_context(|| format!("clone of {url} failed"))?;
829                info!("Git client {} clone successful", client_id);
830                let _ = status_tx.send(format!(
831                    "[CLIENT] Git client {} clone successful",
832                    client_id
833                ));
834                Ok(OperationOutcome {
835                    detail: format!("git_clone {url} -> {shown}"),
836                    output: None,
837                    repo_path: Some(destination),
838                })
839            }
840            "git_fetch" => {
841                let remote_name = data
842                    .get("remote")
843                    .and_then(|v| v.as_str())
844                    .unwrap_or("origin");
845                let path = require_repo()?;
846
847                info!(
848                    "Git client {} fetching from remote {}",
849                    client_id, remote_name
850                );
851                Self::git_fetch(&path, remote_name, credentials)
852                    .with_context(|| format!("fetch from {remote_name} failed"))?;
853                Ok(OperationOutcome::summary(format!(
854                    "git_fetch from '{remote_name}'"
855                )))
856            }
857            "git_status" => {
858                let path = require_repo()?;
859                info!("Git client {} getting status", client_id);
860                let status_text = Self::git_status(&path).context("status failed")?;
861                info!("Git client {} status: {}", client_id, status_text);
862                Ok(OperationOutcome::with_output(
863                    format!(
864                        "git_status: {}",
865                        if status_text.trim().is_empty() {
866                            "clean working tree".to_string()
867                        } else {
868                            status_text.trim().replace('\n', "; ")
869                        }
870                    ),
871                    status_text,
872                ))
873            }
874            "git_list_branches" => {
875                let include_remote = data
876                    .get("remote")
877                    .and_then(|v| v.as_bool())
878                    .unwrap_or(false);
879                let path = require_repo()?;
880
881                info!("Git client {} listing branches", client_id);
882                let branches = Self::git_list_branches(&path, include_remote)
883                    .context("list branches failed")?;
884                info!("Git client {} branches: {}", client_id, branches.join(", "));
885                Ok(OperationOutcome::with_output(
886                    format!("git_list_branches: {}", branches.join(", ")),
887                    branches.join("\n"),
888                ))
889            }
890            "git_log" => {
891                let max_count =
892                    data.get("max_count").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
893                let path = require_repo()?;
894
895                info!("Git client {} getting log (max {})", client_id, max_count);
896                let log_text = Self::git_log(&path, max_count).context("log failed")?;
897                info!("Git client {} log retrieved", client_id);
898                debug!("Log:\n{}", log_text);
899                // The log itself goes to the model, not just its line count: an
900                // instruction like "show me the last 5 commits" is unanswerable otherwise.
901                Ok(OperationOutcome::with_output(
902                    format!("git_log: {} line(s)", log_text.lines().count()),
903                    log_text,
904                ))
905            }
906            "git_pull" => {
907                let remote_name = data
908                    .get("remote")
909                    .and_then(|v| v.as_str())
910                    .unwrap_or("origin");
911                let branch = data.get("branch").and_then(|v| v.as_str());
912                let path = require_repo()?;
913
914                info!("Git client {} pulling from {}", client_id, remote_name);
915                let result = Self::git_pull(&path, remote_name, branch, credentials)
916                    .with_context(|| format!("pull from {remote_name} failed"))?;
917                info!("Git client {} pull: {}", client_id, result);
918                Ok(OperationOutcome::with_output(
919                    format!("git_pull: {result}"),
920                    result,
921                ))
922            }
923            "git_push" => {
924                let remote_name = data
925                    .get("remote")
926                    .and_then(|v| v.as_str())
927                    .unwrap_or("origin");
928                let branch = data.get("branch").and_then(|v| v.as_str());
929                // Gated separately from path confinement, because no allow-list of local
930                // directories bounds where a push *goes*: the remote URL comes out of the
931                // cloned repository's own config and the push carries this client's
932                // credentials. Deleting the workspace does not undo a push.
933                sandbox.require_remote_writes("git_push")?;
934                let path = require_repo()?;
935
936                info!("Git client {} pushing to {}", client_id, remote_name);
937                let result = Self::git_push(&path, remote_name, branch, credentials)
938                    .with_context(|| format!("push to {remote_name} failed"))?;
939                info!("Git client {} push: {}", client_id, result);
940                Ok(OperationOutcome::with_output(
941                    format!("git_push: {result}"),
942                    result,
943                ))
944            }
945            "git_checkout" => {
946                let target = data
947                    .get("target")
948                    .and_then(|v| v.as_str())
949                    .context("Missing 'target' field")?;
950                let create = data
951                    .get("create")
952                    .and_then(|v| v.as_bool())
953                    .unwrap_or(false);
954                let path = require_repo()?;
955
956                info!("Git client {} checking out {}", client_id, target);
957                let result = Self::git_checkout(&path, target, create)
958                    .with_context(|| format!("checkout of {target} failed"))?;
959                info!("Git client {} checkout: {}", client_id, result);
960                Ok(OperationOutcome::with_output(
961                    format!("git_checkout: {result}"),
962                    result,
963                ))
964            }
965            "git_delete_branch" => {
966                let branch = data
967                    .get("branch")
968                    .and_then(|v| v.as_str())
969                    .context("Missing 'branch' field")?;
970                let force = data.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
971                let remote = data.get("remote").and_then(|v| v.as_str());
972                // Only the *remote* half is gated. Deleting a local branch — `force` or
973                // not — destroys history inside a scratch clone, which is what a scratch
974                // clone is for, and gating it would train the operator to leave the flag on
975                // for routine work. Deleting a branch on a real forge is not recoverable by
976                // deleting the workspace, so it is opt-in.
977                if remote.is_some() {
978                    sandbox.require_remote_writes("git_delete_branch with a 'remote'")?;
979                }
980                let path = require_repo()?;
981
982                info!("Git client {} deleting branch {}", client_id, branch);
983                let result = Self::git_delete_branch(&path, branch, force, remote, credentials)
984                    .with_context(|| format!("delete of branch {branch} failed"))?;
985                info!("Git client {} delete branch: {}", client_id, result);
986                Ok(OperationOutcome::with_output(
987                    format!("git_delete_branch: {result}"),
988                    result,
989                ))
990            }
991            "git_list_tags" => {
992                let path = require_repo()?;
993                info!("Git client {} listing tags", client_id);
994                let tags = Self::git_list_tags(&path).context("list tags failed")?;
995                info!("Git client {} tags: {}", client_id, tags);
996                Ok(OperationOutcome::with_output(
997                    format!("git_list_tags: {tags}"),
998                    tags,
999                ))
1000            }
1001            "git_create_tag" => {
1002                let tag_name = data
1003                    .get("name")
1004                    .and_then(|v| v.as_str())
1005                    .context("Missing 'name' field")?;
1006                let target = data.get("target").and_then(|v| v.as_str());
1007                let message = data.get("message").and_then(|v| v.as_str());
1008                let path = require_repo()?;
1009
1010                info!("Git client {} creating tag {}", client_id, tag_name);
1011                let result = Self::git_create_tag(&path, tag_name, target, message)
1012                    .with_context(|| format!("creation of tag {tag_name} failed"))?;
1013                info!("Git client {} create tag: {}", client_id, result);
1014                Ok(OperationOutcome::with_output(
1015                    format!("git_create_tag: {result}"),
1016                    result,
1017                ))
1018            }
1019            "git_diff" => {
1020                let target = data.get("target").and_then(|v| v.as_str());
1021                let staged = data
1022                    .get("staged")
1023                    .and_then(|v| v.as_bool())
1024                    .unwrap_or(false);
1025                let path = require_repo()?;
1026
1027                info!("Git client {} getting diff", client_id);
1028                let diff_text = Self::git_diff(&path, target, staged).context("diff failed")?;
1029                info!("Git client {} diff: {}", client_id, diff_text);
1030                Ok(OperationOutcome::with_output(
1031                    format!("git_diff: {} byte(s) of diff", diff_text.len()),
1032                    diff_text,
1033                ))
1034            }
1035            other => {
1036                debug!("Unhandled Git action: {}", other);
1037                Ok(OperationOutcome::summary(format!(
1038                    "custom result '{other}' is not handled by the Git client"
1039                )))
1040            }
1041        }
1042    }
1043
1044    /// Clone a Git repository
1045    fn git_clone(
1046        url: &str,
1047        path: &str,
1048        credentials: Option<&credentials::CredentialScope>,
1049    ) -> Result<Repository> {
1050        let mut builder = git2::build::RepoBuilder::new();
1051        builder.fetch_options(credentials::fetch_options(credentials));
1052
1053        let repo = builder.clone(url, std::path::Path::new(path))?;
1054        Ok(repo)
1055    }
1056
1057    /// Fetch from a remote
1058    fn git_fetch(
1059        path: &PathBuf,
1060        remote_name: &str,
1061        credentials: Option<&credentials::CredentialScope>,
1062    ) -> Result<()> {
1063        let repo = Repository::open(path)?;
1064        let mut remote = repo.find_remote(remote_name)?;
1065
1066        let mut fetch_options = credentials::fetch_options(credentials);
1067
1068        remote.fetch(
1069            &["refs/heads/*:refs/remotes/origin/*"],
1070            Some(&mut fetch_options),
1071            None,
1072        )?;
1073        Ok(())
1074    }
1075
1076    /// Get repository status
1077    fn git_status(path: &PathBuf) -> Result<String> {
1078        let repo = Repository::open(path)?;
1079        let statuses = repo.statuses(Some(StatusOptions::new().include_untracked(true)))?;
1080
1081        let mut result = String::new();
1082        for entry in statuses.iter() {
1083            if let Some(path) = entry.path() {
1084                let status = entry.status();
1085                result.push_str(&format!("{:?} - {}\n", status, path));
1086            }
1087        }
1088
1089        if result.is_empty() {
1090            result = "Working tree clean".to_string();
1091        }
1092
1093        Ok(result)
1094    }
1095
1096    /// List branches
1097    fn git_list_branches(path: &PathBuf, include_remote: bool) -> Result<Vec<String>> {
1098        let repo = Repository::open(path)?;
1099        let mut branches = Vec::new();
1100
1101        let local_branches = repo.branches(Some(BranchType::Local))?;
1102        for branch in local_branches {
1103            let (branch, _) = branch?;
1104            if let Some(name) = branch.name()? {
1105                branches.push(name.to_string());
1106            }
1107        }
1108
1109        if include_remote {
1110            let remote_branches = repo.branches(Some(BranchType::Remote))?;
1111            for branch in remote_branches {
1112                let (branch, _) = branch?;
1113                if let Some(name) = branch.name()? {
1114                    branches.push(name.to_string());
1115                }
1116            }
1117        }
1118
1119        Ok(branches)
1120    }
1121
1122    /// Get commit log
1123    fn git_log(path: &PathBuf, max_count: usize) -> Result<String> {
1124        let repo = Repository::open(path)?;
1125        let mut revwalk = repo.revwalk()?;
1126        revwalk.push_head()?;
1127
1128        let mut result = String::new();
1129        let mut count = 0;
1130
1131        for oid in revwalk {
1132            if count >= max_count {
1133                break;
1134            }
1135
1136            let oid = oid?;
1137            let commit = repo.find_object(oid, Some(ObjectType::Commit))?;
1138            let commit = commit.as_commit().context("Not a commit")?;
1139
1140            let time = commit.time();
1141            let datetime = chrono::DateTime::from_timestamp(time.seconds(), 0)
1142                .map(|dt| dt.naive_utc())
1143                .unwrap_or_default();
1144
1145            result.push_str(&format!(
1146                "commit {}\nAuthor: {}\nDate: {}\n\n    {}\n\n",
1147                oid,
1148                commit.author(),
1149                datetime.format("%Y-%m-%d %H:%M:%S"),
1150                commit.message().unwrap_or("")
1151            ));
1152
1153            count += 1;
1154        }
1155
1156        Ok(result)
1157    }
1158
1159    /// Pull updates from remote (fetch + merge)
1160    fn git_pull(
1161        path: &PathBuf,
1162        remote_name: &str,
1163        branch_name: Option<&str>,
1164        credentials: Option<&credentials::CredentialScope>,
1165    ) -> Result<String> {
1166        let repo = Repository::open(path)?;
1167
1168        // Get current branch if not specified
1169        let current_branch_name = if let Some(branch) = branch_name {
1170            branch.to_string()
1171        } else {
1172            let head = repo.head()?;
1173            head.shorthand()
1174                .context("Could not get current branch name")?
1175                .to_string()
1176        };
1177
1178        // Fetch first
1179        let mut remote = repo.find_remote(remote_name)?;
1180        let mut fetch_options = credentials::fetch_options(credentials);
1181
1182        remote.fetch(
1183            &[format!(
1184                "refs/heads/{}:refs/remotes/{}/{}",
1185                current_branch_name, remote_name, current_branch_name
1186            )],
1187            Some(&mut fetch_options),
1188            None,
1189        )?;
1190
1191        // Now merge the fetched changes
1192        let fetch_head = repo.find_reference("FETCH_HEAD")?;
1193        let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
1194
1195        // Perform the merge analysis
1196        let (analysis, _) = repo.merge_analysis(&[&fetch_commit])?;
1197
1198        if analysis.is_up_to_date() {
1199            Ok("Already up to date".to_string())
1200        } else if analysis.is_fast_forward() {
1201            // Fast-forward merge
1202            let refname = format!("refs/heads/{}", current_branch_name);
1203            let mut reference = repo.find_reference(&refname)?;
1204            reference.set_target(fetch_commit.id(), "pull: Fast-forward")?;
1205            repo.set_head(&refname)?;
1206            repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?;
1207            Ok(format!("Fast-forward merge completed"))
1208        } else if analysis.is_normal() {
1209            // Normal merge (requires commit)
1210            Ok("Merge required but auto-merge not implemented. Please manually merge.".to_string())
1211        } else {
1212            Ok("Unknown merge analysis result".to_string())
1213        }
1214    }
1215
1216    /// Push commits to remote
1217    fn git_push(
1218        path: &PathBuf,
1219        remote_name: &str,
1220        branch_name: Option<&str>,
1221        credentials: Option<&credentials::CredentialScope>,
1222    ) -> Result<String> {
1223        let repo = Repository::open(path)?;
1224
1225        // Get current branch if not specified
1226        let current_branch_name = if let Some(branch) = branch_name {
1227            branch.to_string()
1228        } else {
1229            let head = repo.head()?;
1230            head.shorthand()
1231                .context("Could not get current branch name")?
1232                .to_string()
1233        };
1234
1235        let mut remote = repo.find_remote(remote_name)?;
1236        let mut push_options = credentials::push_options(credentials);
1237
1238        // Push the branch
1239        let refspec = format!(
1240            "refs/heads/{}:refs/heads/{}",
1241            current_branch_name, current_branch_name
1242        );
1243        remote.push(&[&refspec], Some(&mut push_options))?;
1244
1245        Ok(format!(
1246            "Successfully pushed {} to {}",
1247            current_branch_name, remote_name
1248        ))
1249    }
1250
1251    /// Checkout a branch or create a new branch
1252    fn git_checkout(path: &PathBuf, target: &str, create: bool) -> Result<String> {
1253        let repo = Repository::open(path)?;
1254
1255        if create {
1256            // Create and checkout new branch
1257            let head = repo.head()?;
1258            let oid = head.target().context("Could not get HEAD target")?;
1259            let commit = repo.find_commit(oid)?;
1260
1261            repo.branch(target, &commit, false)?;
1262
1263            let obj = repo.revparse_single(&format!("refs/heads/{}", target))?;
1264            repo.checkout_tree(&obj, None)?;
1265            repo.set_head(&format!("refs/heads/{}", target))?;
1266
1267            Ok(format!("Created and checked out new branch: {}", target))
1268        } else {
1269            // Checkout existing branch or commit
1270            let obj = repo.revparse_single(target)?;
1271            repo.checkout_tree(&obj, None)?;
1272
1273            // Try to set HEAD to the branch reference if it exists
1274            let refname = format!("refs/heads/{}", target);
1275            if repo.find_reference(&refname).is_ok() {
1276                repo.set_head(&refname)?;
1277                Ok(format!("Checked out branch: {}", target))
1278            } else {
1279                // Detached HEAD for commit
1280                repo.set_head_detached(obj.id())?;
1281                Ok(format!("Checked out commit: {} (detached HEAD)", target))
1282            }
1283        }
1284    }
1285
1286    /// Delete a local or remote branch
1287    fn git_delete_branch(
1288        path: &PathBuf,
1289        branch_name: &str,
1290        force: bool,
1291        remote_name: Option<&str>,
1292        credentials: Option<&credentials::CredentialScope>,
1293    ) -> Result<String> {
1294        let repo = Repository::open(path)?;
1295        let mut result_msgs = Vec::new();
1296
1297        // Delete local branch if no remote specified, or always delete local
1298        if remote_name.is_none() {
1299            let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
1300
1301            // Check if branch is fully merged (unless force is true)
1302            if !force {
1303                let head = repo.head()?;
1304                let head_commit = head.peel_to_commit()?;
1305
1306                let branch_ref = branch.get();
1307                let branch_commit = branch_ref.peel_to_commit()?;
1308
1309                // Check if branch is merged into HEAD
1310                let merge_base = repo.merge_base(head_commit.id(), branch_commit.id())?;
1311                if merge_base != branch_commit.id() {
1312                    anyhow::bail!(
1313                        "Branch '{}' is not fully merged. Use force=true to delete anyway.",
1314                        branch_name
1315                    );
1316                }
1317            }
1318
1319            branch.delete()?;
1320            result_msgs.push(format!("Deleted local branch: {}", branch_name));
1321        }
1322
1323        // Delete remote branch if specified
1324        if let Some(remote) = remote_name {
1325            let mut remote_obj = repo.find_remote(remote)?;
1326
1327            let mut push_options = credentials::push_options(credentials);
1328
1329            // Push empty refspec to delete remote branch
1330            let refspec = format!(":refs/heads/{}", branch_name);
1331            remote_obj.push(&[&refspec], Some(&mut push_options))?;
1332
1333            result_msgs.push(format!("Deleted remote branch: {}/{}", remote, branch_name));
1334        }
1335
1336        Ok(result_msgs.join("; "))
1337    }
1338
1339    /// List all tags in the repository
1340    fn git_list_tags(path: &PathBuf) -> Result<String> {
1341        let repo = Repository::open(path)?;
1342        let tag_names = repo.tag_names(None)?;
1343
1344        let mut tags = Vec::new();
1345        for tag_name in tag_names.iter() {
1346            if let Some(name) = tag_name {
1347                tags.push(name.to_string());
1348            }
1349        }
1350
1351        if tags.is_empty() {
1352            Ok("No tags found".to_string())
1353        } else {
1354            Ok(format!("Tags ({}): {}", tags.len(), tags.join(", ")))
1355        }
1356    }
1357
1358    /// Create a new tag
1359    fn git_create_tag(
1360        path: &PathBuf,
1361        tag_name: &str,
1362        target: Option<&str>,
1363        message: Option<&str>,
1364    ) -> Result<String> {
1365        let repo = Repository::open(path)?;
1366
1367        // Resolve target (default to HEAD)
1368        let target_str = target.unwrap_or("HEAD");
1369        let obj = repo.revparse_single(target_str)?;
1370        let target_commit = obj.peel_to_commit()?;
1371
1372        // Get git signature for annotated tags
1373        let sig = repo.signature().or_else(|_| {
1374            // Fallback signature if not configured
1375            git2::Signature::now("NetGet", "netget@localhost")
1376        })?;
1377
1378        if let Some(msg) = message {
1379            // Create annotated tag
1380            repo.tag(tag_name, &obj, &sig, msg, false)?;
1381            Ok(format!(
1382                "Created annotated tag '{}' at {} with message: {}",
1383                tag_name,
1384                target_commit.id(),
1385                msg
1386            ))
1387        } else {
1388            // Create lightweight tag
1389            repo.tag_lightweight(tag_name, &obj, false)?;
1390            Ok(format!(
1391                "Created lightweight tag '{}' at {}",
1392                tag_name,
1393                target_commit.id()
1394            ))
1395        }
1396    }
1397
1398    /// View differences in the repository
1399    fn git_diff(path: &PathBuf, target: Option<&str>, staged: bool) -> Result<String> {
1400        let repo = Repository::open(path)?;
1401
1402        let diff = if staged {
1403            // Show staged changes (index vs HEAD)
1404            let head_tree = repo.head()?.peel_to_tree()?;
1405            let mut index = repo.index()?;
1406            let index_tree = repo.find_tree(index.write_tree()?)?;
1407            repo.diff_tree_to_tree(Some(&head_tree), Some(&index_tree), None)?
1408        } else if let Some(target_ref) = target {
1409            // Show diff against specific target
1410            let target_obj = repo.revparse_single(target_ref)?;
1411            let target_tree = target_obj.peel_to_tree()?;
1412            let head_tree = repo.head()?.peel_to_tree()?;
1413            repo.diff_tree_to_tree(Some(&target_tree), Some(&head_tree), None)?
1414        } else {
1415            // Show working directory changes (working dir vs index)
1416            repo.diff_index_to_workdir(None, None)?
1417        };
1418
1419        // Format diff statistics
1420        let stats = diff.stats()?;
1421        let files_changed = stats.files_changed();
1422        let insertions = stats.insertions();
1423        let deletions = stats.deletions();
1424
1425        // Get patch text
1426        let mut patch_text = String::new();
1427        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
1428            let origin = line.origin();
1429            let content = std::str::from_utf8(line.content()).unwrap_or("");
1430
1431            match origin {
1432                '+' | '-' | ' ' => {
1433                    patch_text.push(origin);
1434                    patch_text.push_str(content);
1435                }
1436                _ => {
1437                    patch_text.push_str(content);
1438                }
1439            }
1440            true
1441        })?;
1442
1443        if patch_text.is_empty() {
1444            Ok("No differences found".to_string())
1445        } else {
1446            Ok(format!(
1447                "Diff: {} file(s) changed, {} insertion(s), {} deletion(s)\n\n{}",
1448                files_changed,
1449                insertions,
1450                deletions,
1451                patch_text.lines().take(50).collect::<Vec<_>>().join("\n")
1452            ))
1453        }
1454    }
1455}
1456
1457/// What one git2 operation produced.
1458struct OperationOutcome {
1459    /// One-line summary for the operator and the log.
1460    detail: String,
1461    /// The text the operation produced — commit log, diff, branch list, status — for the
1462    /// model to read. `None` for operations whose whole result fits in `detail`.
1463    ///
1464    /// Before this existed, `git_log` reported "12 line(s)" and threw the log away, so a
1465    /// model instructed to "show me the last 5 commits" could never see one.
1466    output: Option<String>,
1467    /// The repository the operation established, when it established one (`git_clone`).
1468    repo_path: Option<PathBuf>,
1469}
1470
1471impl OperationOutcome {
1472    fn summary(detail: impl Into<String>) -> Self {
1473        Self {
1474            detail: detail.into(),
1475            output: None,
1476            repo_path: None,
1477        }
1478    }
1479
1480    fn with_output(detail: impl Into<String>, output: impl Into<String>) -> Self {
1481        Self {
1482            detail: detail.into(),
1483            output: Some(output.into()),
1484            repo_path: None,
1485        }
1486    }
1487}
1488
1489/// An action the Git protocol itself refused (unknown type, missing field), as opposed
1490/// to a git2 operation that ran and failed. The injected-command loop maps the first to
1491/// `ClientSendOutcome::Rejected` and the second to an error.
1492#[derive(Debug)]
1493struct RejectedAction(String);
1494
1495impl std::fmt::Display for RejectedAction {
1496    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1497        f.write_str(&self.0)
1498    }
1499}
1500
1501impl std::error::Error for RejectedAction {}