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
| 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 | |
| 12 | use crate::error::{Error, Result}; |
| 13 | use std::path::{Path, PathBuf}; |
| 14 | use std::process::Output; |
| 15 | |
| 16 | /// A prepared `git` invocation. |
| 17 | pub struct Git { |
| 18 | git_dir: Option<PathBuf>, |
| 19 | } |
| 20 | |
| 21 | impl 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. |
| 117 | pub async fn blocking<F, T>(work: F) -> Result<T> |
| 118 | where |
| 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. |
| 128 | pub 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)] |
| 152 | mod 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 | } |