zuka
zuka/src/git/exec.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/git/exec.rs
RSexec.rs7.4 KBDownload
1// The single place this service invokes `git`.
2//
3// Before this existed there were five wrappers and nine ad-hoc spawns across six
4// modules, with three different environment policies — so `/etc/gitconfig` applied
5// to `git init`, `git config`, `merge-base --is-ancestor` (the ref rule) and
6// `count-objects` (quota) but not to the wire protocol. Half a control is not a
7// control.
8//
9// Every invocation here clears the environment and sets `GIT_CONFIG_NOSYSTEM`, so
10// host configuration cannot change what any of our git calls do.
11
12use crate::error::{Error, Result};
13use std::path::{Path, PathBuf};
14use std::process::Output;
15
16/// A prepared `git` invocation.
17pub struct Git {
18 git_dir: Option<PathBuf>,
19}
20
21impl Git {
22 /// A repository-scoped invocation (`git --git-dir <dir> ...`).
23 pub fn at(git_dir: impl Into<PathBuf>) -> Self {
24 Git {
25 git_dir: Some(git_dir.into()),
26 }
27 }
28
29 /// A repository-less invocation, for `git init` and friends.
30 pub fn plain() -> Self {
31 Git { git_dir: None }
32 }
33
34 fn command(&self, args: &[&str]) -> std::process::Command {
35 let mut command = std::process::Command::new("git");
36 command
37 .env_clear()
38 .env(
39 "PATH",
40 std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".into()),
41 )
42 // Host configuration must not change what our git calls do.
43 .env("GIT_CONFIG_NOSYSTEM", "1")
44 .env("GIT_TERMINAL_PROMPT", "0");
45
46 if let Some(dir) = &self.git_dir {
47 command.arg("--git-dir").arg(dir);
48 }
49 command.args(args);
50 command
51 }
52
53 /// Run and capture, returning the raw output whatever the exit status.
54 pub fn output(&self, args: &[&str]) -> Result<Output> {
55 self.command(args).output().map_err(|e| {
56 Error::Internal(anyhow::Error::from(e).context(format!("spawn git {:?}", args.first())))
57 })
58 }
59
60 /// Run, requiring success. Stderr becomes the error context.
61 pub fn run(&self, args: &[&str]) -> Result<Vec<u8>> {
62 let output = self.output(args)?;
63 if output.status.success() {
64 return Ok(output.stdout);
65 }
66 Err(Error::Internal(anyhow::anyhow!(
67 "git {} failed: {}",
68 args.first().copied().unwrap_or("?"),
69 String::from_utf8_lossy(&output.stderr).trim()
70 )))
71 }
72
73 /// Run, requiring success, returning trimmed stdout as a string.
74 pub fn text(&self, args: &[&str]) -> Result<String> {
75 Ok(String::from_utf8_lossy(&self.run(args)?).trim().to_string())
76 }
77
78 /// Run a query where a non-zero exit is an answer rather than a fault.
79 ///
80 /// git uses exit code 1 for "no such object", "no matches" and similar. Anything
81 /// higher — or a failure to spawn — is a real fault and must not be flattened
82 /// into `404`, or a corrupt repository reports "not found" and nothing pages
83 /// anyone.
84 pub fn query(&self, args: &[&str]) -> Result<Option<Vec<u8>>> {
85 let output = self.output(args)?;
86 match output.status.code() {
87 Some(0) => Ok(Some(output.stdout)),
88 Some(1) => Ok(None),
89 _ => Err(Error::Internal(anyhow::anyhow!(
90 "git {} failed: {}",
91 args.first().copied().unwrap_or("?"),
92 String::from_utf8_lossy(&output.stderr).trim()
93 ))),
94 }
95 }
96
97 /// Whether a command succeeded, discarding its output.
98 pub fn succeeds(&self, args: &[&str]) -> bool {
99 self.output(args)
100 .map(|o| o.status.success())
101 .unwrap_or(false)
102 }
103
104 /// Run without caring whether it succeeded.
105 ///
106 /// For `config --unset-all`, which exits 5 when the key was already absent.
107 pub fn ignore(&self, args: &[&str]) {
108 let _ = self.output(args);
109 }
110}
111
112/// Run blocking git work off the runtime's worker threads.
113///
114/// Every git call is a fork+exec plus disk. Running one on a tokio worker stalls
115/// unrelated requests, including `/healthz`, and the pool is only as wide as the
116/// core count.
117pub async fn blocking<F, T>(work: F) -> Result<T>
118where
119 F: FnOnce() -> Result<T> + Send + 'static,
120 T: Send + 'static,
121{
122 tokio::task::spawn_blocking(work)
123 .await
124 .map_err(|e| Error::Internal(anyhow::anyhow!("git task panicked: {e}")))?
125}
126
127/// Locate `git-http-backend`, resolved once rather than per request.
128pub fn http_backend() -> Result<PathBuf> {
129 use std::sync::OnceLock;
130 static CACHED: OnceLock<std::result::Result<PathBuf, String>> = OnceLock::new();
131
132 CACHED
133 .get_or_init(|| {
134 if let Ok(explicit) = std::env::var(crate::brand::env_name("GIT_HTTP_BACKEND")) {
135 return Ok(PathBuf::from(explicit));
136 }
137 let dir = Git::plain()
138 .text(&["--exec-path"])
139 .map_err(|e| format!("git --exec-path failed: {e}"))?;
140 let path = Path::new(&dir).join("git-http-backend");
141 if path.exists() {
142 Ok(path)
143 } else {
144 Err(format!("git-http-backend not found at {}", path.display()))
145 }
146 })
147 .clone()
148 .map_err(|e| Error::Internal(anyhow::anyhow!(e)))
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 fn bare() -> (tempfile::TempDir, PathBuf) {
156 let dir = tempfile::tempdir().unwrap();
157 let git_dir = dir.path().join("r.git");
158 Git::plain()
159 .run(&["init", "--bare", "--quiet", git_dir.to_str().unwrap()])
160 .unwrap();
161 (dir, git_dir)
162 }
163
164 #[test]
165 fn a_successful_command_returns_its_output() {
166 let (_dir, git_dir) = bare();
167 assert_eq!(
168 Git::at(&git_dir)
169 .text(&["rev-parse", "--is-bare-repository"])
170 .unwrap(),
171 "true"
172 );
173 }
174
175 #[test]
176 fn a_query_distinguishes_no_answer_from_a_fault() {
177 let (_dir, git_dir) = bare();
178 let git = Git::at(&git_dir);
179
180 // Exit 1: the ref does not exist. That is an answer.
181 assert_eq!(
182 git.query(&["rev-parse", "--verify", "--quiet", "refs/heads/nope"])
183 .unwrap(),
184 None
185 );
186
187 // Exit 128: a broken invocation. That is a fault, not "not found" —
188 // flattening it would make a corrupt repository report as empty.
189 let fault = git.query(&["cat-file", "-t", "not-an-object"]);
190 assert!(fault.is_err(), "a real failure must not read as absence");
191 }
192
193 #[test]
194 fn the_environment_is_scrubbed_so_host_config_cannot_change_behaviour() {
195 let (_dir, git_dir) = bare();
196 // If the child inherited our environment, this would set the author name.
197 std::env::set_var("GIT_AUTHOR_NAME", "leaked");
198 let env = Git::at(&git_dir)
199 .text(&["var", "GIT_AUTHOR_IDENT"])
200 .unwrap_or_default();
201 std::env::remove_var("GIT_AUTHOR_NAME");
202
203 assert!(
204 !env.contains("leaked"),
205 "a git child must not inherit this process's environment: {env}"
206 );
207 }
208
209 #[test]
210 fn ignore_swallows_the_expected_failure_of_an_absent_config_key() {
211 let (_dir, git_dir) = bare();
212 // `--unset-all` exits 5 when the key was never set.
213 Git::at(&git_dir).ignore(&["config", "--unset-all", "nosuch.key"]);
214 }
215
216 #[test]
217 fn git_http_backend_is_locatable_on_this_host() {
218 http_backend().expect("the wire protocol needs git-http-backend");
219 }
220}