Skip to main content

Mountain/IPC/WindServiceHandlers/
Git.rs

1#![allow(non_snake_case, unused_variables, dead_code)]
2//! Git subprocess handlers exposed to the renderer via the `localGit`
3//! channel. Mirrors stock VS Code's `ILocalGitService` API
4//! (`src/vs/platform/git/common/localGitService.ts`) - `clone`, `pull`,
5//! `checkout`, `revParse`, `fetch`, `revListCount`, `cancel`. Adds two
6//! Land-specific auxiliaries: `exec` (arbitrary argv, used by the Git
7//! extension) and `isAvailable` (synchronous feature detection).
8//!
9//! Cancellation is keyed on `OperationId`; the shared `RunningProcesses`
10//! map survives across invokes so the renderer can fire `cancel` from a
11//! different Tauri invoke. `tokio::process::Child` doesn't expose a
12//! stable Send handle across threads, so we store the PID instead and
13//! send `SIGTERM` on cancel (Unix) / TerminateProcess via taskkill on
14//! Windows.
15
16use std::{
17	collections::HashMap,
18	path::PathBuf,
19	sync::{Mutex, OnceLock},
20};
21
22use serde_json::{Value, json};
23use tokio::process::Command;
24
25use crate::dev_log;
26
27fn RunningProcesses() -> &'static Mutex<HashMap<String, u32>> {
28	static SLOT:OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
29	SLOT.get_or_init(|| Mutex::new(HashMap::new()))
30}
31
32fn RegisterPid(OperationId:&str, Pid:u32) {
33	if OperationId.is_empty() {
34		return;
35	}
36	if let Ok(mut Map) = RunningProcesses().lock() {
37		Map.insert(OperationId.to_string(), Pid);
38	}
39}
40
41fn ClearPid(OperationId:&str) {
42	if OperationId.is_empty() {
43		return;
44	}
45	if let Ok(mut Map) = RunningProcesses().lock() {
46		Map.remove(OperationId);
47	}
48}
49
50fn TakePid(OperationId:&str) -> Option<u32> {
51	if OperationId.is_empty() {
52		return None;
53	}
54	RunningProcesses().lock().ok().and_then(|mut M| M.remove(OperationId))
55}
56
57fn ResolveCwd(Raw:&str) -> PathBuf {
58	if Raw.is_empty() {
59		std::env::current_dir().unwrap_or_default()
60	} else {
61		PathBuf::from(Raw)
62	}
63}
64
65async fn RunGit(OperationId:&str, Args:&[String], Cwd:Option<&str>) -> Result<(i32, String, String), String> {
66	dev_log!(
67		"git",
68		"[Git] exec-begin op={} cwd={} Arguments=[{}]",
69		OperationId,
70		Cwd.unwrap_or("<inherit>"),
71		Args.join(" ")
72	);
73
74	let WorkingDir = Cwd
75		.map(ResolveCwd)
76		.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
77
78	let mut Spawn = Command::new("git");
79	Spawn.args(Args).current_dir(&WorkingDir).kill_on_drop(true);
80
81	let Child = Spawn.spawn().map_err(|Error| {
82		dev_log!(
83			"git",
84			"[Git] exec-spawn-fail op={} Arguments=[{}] error={}",
85			OperationId,
86			Args.join(" "),
87			Error
88		);
89		format!("git spawn failed: {}", Error)
90	})?;
91
92	if let Some(Pid) = Child.id() {
93		RegisterPid(OperationId, Pid);
94	}
95
96	let Output = Child.wait_with_output().await.map_err(|Error| {
97		ClearPid(OperationId);
98		format!("git wait failed: {}", Error)
99	})?;
100
101	ClearPid(OperationId);
102
103	let ExitCode = Output.status.code().unwrap_or(-1);
104	let Stdout = String::from_utf8_lossy(&Output.stdout).into_owned();
105	let Stderr = String::from_utf8_lossy(&Output.stderr).into_owned();
106
107	dev_log!(
108		"git",
109		"[Git] exec-done op={} Arguments=[{}] exit={} stdout={}B stderr={}B",
110		OperationId,
111		Args.join(" "),
112		ExitCode,
113		Stdout.len(),
114		Stderr.len()
115	);
116
117	Ok((ExitCode, Stdout, Stderr))
118}
119
120fn AsStringArray(Value:&Value) -> Vec<String> {
121	Value
122		.as_array()
123		.map(|Arr| Arr.iter().filter_map(|V| V.as_str().map(str::to_string)).collect())
124		.unwrap_or_default()
125}
126
127fn Generated() -> String { uuid::Uuid::new_v4().to_string() }
128
129/// `localGit:exec` - accept either `{ Arguments, cwd?, operationId? }` or a
130/// legacy positional `(Arguments: string[], cwd?: string)`. Returns
131/// `{ stdout, stderr, exitCode }`.
132pub async fn HandleExec(Arguments:Vec<Value>) -> Result<Value, String> {
133	let (Argv, Cwd, OperationId) = match Arguments.first() {
134		Some(First) if First.is_object() => {
135			let Obj = First.as_object().unwrap();
136			let Argv = Obj.get("Arguments").map(AsStringArray).unwrap_or_default();
137			let Cwd = Obj.get("cwd").and_then(Value::as_str).unwrap_or("").to_string();
138			let OperationId = Obj.get("operationId").and_then(Value::as_str).unwrap_or("").to_string();
139			(Argv, Cwd, OperationId)
140		},
141		Some(First) if First.is_array() => {
142			let Argv = AsStringArray(First);
143			let Cwd = Arguments.get(1).and_then(Value::as_str).unwrap_or("").to_string();
144			(Argv, Cwd, String::new())
145		},
146		_ => (Vec::new(), String::new(), String::new()),
147	};
148
149	if Argv.is_empty() {
150		return Err("git:exec requires non-empty Arguments".to_string());
151	}
152
153	let OperationIdRef = if OperationId.is_empty() { Generated() } else { OperationId };
154	let CwdOpt = if Cwd.is_empty() { None } else { Some(Cwd.as_str()) };
155	let (ExitCode, Stdout, Stderr) = RunGit(&OperationIdRef, &Argv, CwdOpt).await?;
156
157	Ok(json!({
158		"stdout": Stdout,
159		"stderr": Stderr,
160		"exitCode": ExitCode,
161	}))
162}
163
164/// `localGit:clone(operationId, cloneUrl, targetPath, ref?)`
165pub async fn HandleClone(Arguments:Vec<Value>) -> Result<Value, String> {
166	let OperationId = Arguments.first().and_then(Value::as_str).unwrap_or("").to_string();
167	let CloneURL = Arguments.get(1).and_then(Value::as_str).unwrap_or("").to_string();
168	let TargetPath = Arguments.get(2).and_then(Value::as_str).unwrap_or("").to_string();
169	let Reference = Arguments.get(3).and_then(Value::as_str).map(str::to_string);
170
171	if CloneURL.is_empty() || TargetPath.is_empty() {
172		return Err("git:clone requires cloneUrl and targetPath".to_string());
173	}
174
175	let mut Argv:Vec<String> = vec!["clone".to_string()];
176	if let Some(Ref) = Reference {
177		Argv.push("--branch".to_string());
178		Argv.push(Ref);
179	}
180	Argv.push("--".to_string());
181	Argv.push(CloneURL);
182	Argv.push(TargetPath);
183
184	let (ExitCode, _, Stderr) = RunGit(&OperationId, &Argv, None).await?;
185	if ExitCode != 0 {
186		return Err(format!("git clone failed: {}", Stderr));
187	}
188	Ok(Value::Null)
189}
190
191/// `localGit:pull(operationId, repoPath) -> boolean` (true = HEAD moved).
192pub async fn HandlePull(Arguments:Vec<Value>) -> Result<Value, String> {
193	let OperationId = Arguments.first().and_then(Value::as_str).unwrap_or("").to_string();
194	let RepoPath = Arguments.get(1).and_then(Value::as_str).unwrap_or("").to_string();
195	if RepoPath.is_empty() {
196		return Err("git:pull requires repoPath".to_string());
197	}
198
199	let (BeforeExit, Before, _) =
200		RunGit(&OperationId, &["rev-parse".to_string(), "HEAD".to_string()], Some(&RepoPath)).await?;
201	if BeforeExit != 0 {
202		return Err("git:pull: failed to read HEAD before pull".to_string());
203	}
204
205	let (PullExit, _, PullStderr) =
206		RunGit(&OperationId, &["pull".to_string(), "--ff-only".to_string()], Some(&RepoPath)).await?;
207	if PullExit != 0 {
208		return Err(format!("git pull failed: {}", PullStderr));
209	}
210
211	let (AfterExit, After, _) =
212		RunGit(&OperationId, &["rev-parse".to_string(), "HEAD".to_string()], Some(&RepoPath)).await?;
213	if AfterExit != 0 {
214		return Err("git:pull: failed to read HEAD after pull".to_string());
215	}
216
217	Ok(json!(Before.trim() != After.trim()))
218}
219
220/// `localGit:checkout(operationId, repoPath, treeish, detached?)`
221pub async fn HandleCheckout(Arguments:Vec<Value>) -> Result<Value, String> {
222	let OperationId = Arguments.first().and_then(Value::as_str).unwrap_or("").to_string();
223	let RepoPath = Arguments.get(1).and_then(Value::as_str).unwrap_or("").to_string();
224	let Treeish = Arguments.get(2).and_then(Value::as_str).unwrap_or("").to_string();
225	let Detached = Arguments.get(3).and_then(Value::as_bool).unwrap_or(false);
226
227	if RepoPath.is_empty() || Treeish.is_empty() {
228		return Err("git:checkout requires repoPath and treeish".to_string());
229	}
230
231	let Argv:Vec<String> = if Detached {
232		vec!["checkout".to_string(), "--detach".to_string(), Treeish]
233	} else {
234		vec!["checkout".to_string(), Treeish]
235	};
236
237	let (ExitCode, _, Stderr) = RunGit(&OperationId, &Argv, Some(&RepoPath)).await?;
238	if ExitCode != 0 {
239		return Err(format!("git checkout failed: {}", Stderr));
240	}
241	Ok(Value::Null)
242}
243
244/// `localGit:revParse(repoPath, ref) -> string`
245pub async fn HandleRevParse(Arguments:Vec<Value>) -> Result<Value, String> {
246	let RepoPath = Arguments.first().and_then(Value::as_str).unwrap_or("").to_string();
247	let Reference = Arguments.get(1).and_then(Value::as_str).unwrap_or("HEAD").to_string();
248	if RepoPath.is_empty() {
249		return Err("git:revParse requires repoPath".to_string());
250	}
251	let (ExitCode, Stdout, Stderr) =
252		RunGit(&Generated(), &["rev-parse".to_string(), Reference], Some(&RepoPath)).await?;
253	if ExitCode != 0 {
254		return Err(format!("git rev-parse failed: {}", Stderr));
255	}
256	Ok(json!(Stdout.trim()))
257}
258
259/// `localGit:fetch(operationId, repoPath)`
260pub async fn HandleFetch(Arguments:Vec<Value>) -> Result<Value, String> {
261	let OperationId = Arguments.first().and_then(Value::as_str).unwrap_or("").to_string();
262	let RepoPath = Arguments.get(1).and_then(Value::as_str).unwrap_or("").to_string();
263	if RepoPath.is_empty() {
264		return Err("git:fetch requires repoPath".to_string());
265	}
266	let (ExitCode, _, Stderr) = RunGit(&OperationId, &["fetch".to_string()], Some(&RepoPath)).await?;
267	if ExitCode != 0 {
268		return Err(format!("git fetch failed: {}", Stderr));
269	}
270	Ok(Value::Null)
271}
272
273/// `localGit:revListCount(repoPath, fromRef, toRef) -> number`
274pub async fn HandleRevListCount(Arguments:Vec<Value>) -> Result<Value, String> {
275	let RepoPath = Arguments.first().and_then(Value::as_str).unwrap_or("").to_string();
276	let FromRef = Arguments.get(1).and_then(Value::as_str).unwrap_or("").to_string();
277	let ToRef = Arguments.get(2).and_then(Value::as_str).unwrap_or("").to_string();
278	if RepoPath.is_empty() || FromRef.is_empty() || ToRef.is_empty() {
279		return Err("git:revListCount requires repoPath, fromRef, toRef".to_string());
280	}
281	let Range = format!("{}..{}", FromRef, ToRef);
282	let (ExitCode, Stdout, Stderr) = RunGit(
283		&Generated(),
284		&["rev-list".to_string(), "--count".to_string(), Range],
285		Some(&RepoPath),
286	)
287	.await?;
288	if ExitCode != 0 {
289		return Err(format!("git rev-list failed: {}", Stderr));
290	}
291	Ok(json!(Stdout.trim().parse::<u64>().unwrap_or(0)))
292}
293
294/// `localGit:cancel(operationId)` - SIGTERM the pid we stashed for
295/// `operationId`. Silent no-op if unknown.
296pub async fn HandleCancel(Arguments:Vec<Value>) -> Result<Value, String> {
297	let OperationId = Arguments.first().and_then(Value::as_str).unwrap_or("").to_string();
298	if let Some(Pid) = TakePid(&OperationId) {
299		dev_log!("git", "[Git] cancel op={} pid={}", OperationId, Pid);
300		#[cfg(unix)]
301		{
302			let _ = std::process::Command::new("kill").args(["-TERM", &Pid.to_string()]).output();
303		}
304		#[cfg(windows)]
305		{
306			let _ = std::process::Command::new("taskkill")
307				.args(["/PID", &Pid.to_string(), "/T", "/F"])
308				.output();
309		}
310	} else {
311		dev_log!("git", "[Git] cancel op={} pid=<unknown>", OperationId);
312	}
313	Ok(Value::Null)
314}
315
316/// `localGit:isAvailable` - cheap `git --version` probe, cached for the
317/// process lifetime so UI polling doesn't re-exec git.
318pub async fn HandleIsAvailable(_Arguments:Vec<Value>) -> Result<Value, String> {
319	static CACHE:OnceLock<bool> = OnceLock::new();
320	if let Some(Cached) = CACHE.get() {
321		return Ok(json!(*Cached));
322	}
323	let Available = Command::new("git")
324		.arg("--version")
325		.output()
326		.await
327		.map(|O| O.status.success())
328		.unwrap_or(false);
329	let _ = CACHE.set(Available);
330	dev_log!("git", "[Git] isAvailable={}", Available);
331	Ok(json!(Available))
332}