1pub 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
30struct GitSession {
37 repo_path: Option<PathBuf>,
38 local_path: Option<String>,
41 credentials: Option<credentials::CredentialScope>,
44 sandbox: GitSandbox,
48}
49
50async 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
81async 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 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
124enum Applied {
127 Ran {
131 detail: String,
132 output: Option<String>,
133 },
134 Disconnect,
136}
137
138impl Applied {
139 fn ran(detail: impl Into<String>) -> Self {
141 Applied::Ran {
142 detail: detail.into(),
143 output: None,
144 }
145 }
146}
147
148const MAX_FOLLOWUP_DEPTH: u8 = 6;
152
153const MAX_OUTPUT_BYTES_FOR_MODEL: usize = 8000;
156
157pub struct GitClient;
159
160impl GitClient {
161 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 info!(
177 "Git client {} initializing with target: {}",
178 client_id, remote_addr
179 );
180
181 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 let dummy_addr: SocketAddr = "127.0.0.1:0".parse()?;
191
192 let instruction = app_state
194 .get_instruction_for_client(client_id)
195 .await
196 .unwrap_or_default();
197
198 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 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 .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 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 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 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 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 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 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 if let Some(mem) = memory_updates {
405 app_state.set_memory_for_client(client_id, mem).await;
406 }
407
408 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 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 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 Ok(Applied::Ran { detail, .. }) => Ok(ClientSendOutcome::Executed { detail }),
486 Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
487 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 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 #[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 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 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 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 #[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 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 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 let destination = sandbox.resolve(requested_path, "the git_clone 'path'")?;
802
803 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 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 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 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 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 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 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 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 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 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 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 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 let fetch_head = repo.find_reference("FETCH_HEAD")?;
1193 let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
1194
1195 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 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 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 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 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 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 fn git_checkout(path: &PathBuf, target: &str, create: bool) -> Result<String> {
1253 let repo = Repository::open(path)?;
1254
1255 if create {
1256 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 let obj = repo.revparse_single(target)?;
1271 repo.checkout_tree(&obj, None)?;
1272
1273 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 repo.set_head_detached(obj.id())?;
1281 Ok(format!("Checked out commit: {} (detached HEAD)", target))
1282 }
1283 }
1284 }
1285
1286 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 if remote_name.is_none() {
1299 let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
1300
1301 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 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 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 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 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 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 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 let sig = repo.signature().or_else(|_| {
1374 git2::Signature::now("NetGet", "netget@localhost")
1376 })?;
1377
1378 if let Some(msg) = message {
1379 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 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 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 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 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 repo.diff_index_to_workdir(None, None)?
1417 };
1418
1419 let stats = diff.stats()?;
1421 let files_changed = stats.files_changed();
1422 let insertions = stats.insertions();
1423 let deletions = stats.deletions();
1424
1425 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
1457struct OperationOutcome {
1459 detail: String,
1461 output: Option<String>,
1467 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#[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 {}