#![allow(dead_code)] use futures::future::TryFutureExt; use pathdiff::diff_paths; use std::collections::HashMap; use std::fmt; use std::path::{Path, PathBuf}; use std::process::Stdio; use tokio::fs; use tokio::process::Command; use tokio::sync::{RwLock, Semaphore}; use crate::fs_utils; #[derive(Debug)] pub struct Error { pub message: String, } impl Error { fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.message) } } impl std::error::Error for Error {} pub const EMPTY: &str = "0000000000000000000000000000000000000000"; struct RepoData { // Only one fetch at a time, and they should be in queue fetch_semaphore: Semaphore, config_cache: HashMap, } pub struct Repository { path: PathBuf, bare: bool, remote: Option, project_id: Option, socket: Option, githook: Option, ssh_config: Option, // Lock for any repo task, 90% of all tasks are readers but there are some writers // where nothing else may be done. lock: RwLock, } #[allow(dead_code)] pub struct User { pub name: String, pub email: String, // Part before '@' in email pub username: String, } #[derive(Debug, PartialEq)] pub enum ObjectType { BLOB, COMMIT, TREE, } #[derive(Debug, PartialEq)] pub struct TreeEntry { pub object_type: ObjectType, pub object_name: String, pub path: String, } fn io_err(action: &str, e: std::io::Error) -> Error { Error::new(format!("{action}: {e}")) } fn parse_user(output: String) -> User { let mut lines = output.split_terminator('\0'); let name = lines.next().unwrap_or("").to_string(); let username = lines.next().unwrap_or("").to_string(); let email = lines.next().unwrap_or("").to_string(); User { name, email, username, } } fn parse_tree_entries(output: String) -> Vec { let mut ret = Vec::new(); for line in output.split_terminator('\0') { let mut parts = line.split_terminator('\t'); let mut parts2 = parts.next().unwrap_or("").split_terminator(' '); parts2.next(); // object mode let object_type = match parts2.next() { Some(value) => match value { "blob" => ObjectType::BLOB, "commit" => ObjectType::COMMIT, "tree" => ObjectType::TREE, _ => continue, }, None => continue, }; if let Some(object_name) = parts2.next() { if let Some(path) = parts.next() { ret.push(TreeEntry { object_type, object_name: object_name.to_string(), path: path.to_string(), }); } } } ret } impl RepoData { fn new() -> Self { Self { fetch_semaphore: Semaphore::new(1), config_cache: HashMap::new(), } } async fn fetch(&self, repo: &Repository, branch: String) -> Result<(), Error> { if repo.remote.is_none() { return Err(Error::new("No remote set")); } let _permit = self.fetch_semaphore.acquire().await; let mut cmd = self.git_cmd(repo); cmd.arg("fetch"); // Use an atomic transaction to update local refs. cmd.arg("--atomic"); // Print the output to standard output in an easy-to-parse format for scripts. cmd.arg("--porcelain"); // This option disables this automatic tag following. cmd.arg("--no-tags"); cmd.arg("origin"); // <+ force update>: cmd.arg(format!("+{branch}:{branch}")); self.output(&mut cmd).await?; Ok(()) } async fn init(&mut self, repo: &Repository) -> Result<(), Error> { fs_utils::create_dir_allow_existing(repo.path()) .map_err(|e| Error::new(format!("{e}"))) .await?; let mut cmd = self.git_cmd(repo); cmd.arg("init"); if repo.is_bare() { cmd.arg("--bare"); } self.run(&mut cmd).await?; Ok(()) } async fn sync_config(&mut self, repo: &Repository) -> Result<(), Error> { self.config_fill_cache(repo).await?; if let Some(remote) = repo.remote() { self.config_set(repo, "remote.origin.url", remote).await?; } if let Some(socket) = repo.socket() { let relative = diff_paths(socket, repo.path()).unwrap(); self.config_set(repo, "eyeballs.socket", relative.to_str().unwrap()) .await?; } if let Some(ssh_config) = repo.ssh_config() { let relative = diff_paths(ssh_config, repo.path()).unwrap(); self.config_set( repo, "core.sshcommand", format!("ssh -F {}", relative.to_str().unwrap()).as_str(), ) .await?; } // Handled by pre-receive hook, allow fast forwards for reviews that expect it. self.config_set(repo, "receive.denyNonFastForwards", "false") .await?; // Handled by pre-receive hook, allow deletes for non-review branches self.config_set(repo, "receive.denyDeletes", "false") .await?; Ok(()) } async fn sync_hooks(&mut self, repo: &Repository) -> Result<(), Error> { let hook = match repo.githook() { Some(path) => PathBuf::from(path), None => { let server_exe = std::env::current_exe().map_err(|e| io_err("unable to get current exe", e))?; server_exe.parent().unwrap().join("eyeballs-githook") } }; let hooks = if repo.is_bare() { repo.path().join("hooks") } else { repo.path().join(".git/hooks") }; fs_utils::create_dir_allow_existing(&hooks) .map_err(|e| io_err("unable to create hooks", e)) .await?; let pre_receive = hooks.join("pre-receive"); let update = hooks.join("update"); let post_receive = hooks.join("post-receive"); fs_utils::remove_file_allow_not_found(update) .map_err(|e| io_err("unable to remove update hook", e)) .await?; // Must be hard links, symbolic links doesn't allow the hook // the lookup how it's called using std::env::current_exe(). fs_utils::remove_file_allow_not_found(&pre_receive) .map_err(|e| io_err("unable to remove pre-receive hook", e)) .await?; fs::hard_link(hook.as_path(), pre_receive) .map_err(|e| io_err("unable to link pre-receive hook", e)) .await?; fs_utils::remove_file_allow_not_found(&post_receive) .map_err(|e| io_err("unable to remove post-receive hook", e)) .await?; fs::hard_link(hook.as_path(), post_receive) .map_err(|e| io_err("unable to link post-receive hook", e)) .await } fn canonical_config_name(name: &str) -> String { let mut ret = String::with_capacity(name.len()); let mut iter = name.splitn(3, '.'); ret.push_str(iter.next().unwrap()); ret.as_mut_str().make_ascii_lowercase(); if let Some(subsection) = iter.next() { ret.push('.'); let offset; if let Some(value) = iter.next() { ret.push_str(subsection); ret.push('.'); offset = ret.len(); ret.push_str(value); } else { offset = ret.len(); ret.push_str(subsection); } ret.as_mut_str() .get_mut(offset..) .unwrap() .make_ascii_lowercase(); } ret } async fn config_get(&self, repo: &Repository, name: &str) -> Result { let name = Self::canonical_config_name(name); if let Some(value) = self.config_cache.get(&name) { return Ok(value.clone()); } // Note, want to keep this method non-mutable so we can't update the cache here, should be // edge case to end up here anyway. let mut cmd = self.git_cmd(repo); cmd.arg("config") .arg("get") // End value with the null character and use newline as delimiter between key and value .arg("--null") .arg("--default=") .arg(name); let data = self.output(&mut cmd).await?; match data.as_str().split_once('\0') { Some((value, _)) => Ok(value.to_string()), None => Err(Error::new("Invalid output from git config get")), } } async fn config_fill_cache(&mut self, repo: &Repository) -> Result<(), Error> { self.config_cache.clear(); let mut cmd = self.git_cmd(repo); cmd.arg("config") .arg("list") // read only from the repository .git/config, .arg("--local") // End value with the null character and use newline as delimiter between key and value .arg("--null"); let data = self.output(&mut cmd).await?; for key_value in data.split_terminator('\0') { match key_value.split_once('\n') { Some((key, value)) => { self.config_cache.insert(key.to_string(), value.to_string()); } None => return Err(Error::new("Invalid output from git config list")), }; } Ok(()) } async fn config_set( &mut self, repo: &Repository, name: &str, value: &str, ) -> Result<(), Error> { let name = Self::canonical_config_name(name); if let Some(cached_value) = self.config_cache.get(&name) { if cached_value == value { return Ok(()); } } let mut cmd = self.git_cmd(repo); cmd.arg("config").arg("set").arg(&name).arg(value); self.run(&mut cmd).await?; self.config_cache.insert(name, value.to_string()); Ok(()) } async fn is_ancestor( &self, repo: &Repository, ancestor: &str, commit: &str, ) -> Result { let mut cmd = self.git_cmd(repo); cmd.arg("merge-base") .arg("--is-ancestor") .arg(ancestor) .arg(commit); self.check(&mut cmd).await } async fn is_equal_content( &self, repo: &Repository, commit1: &str, commit2: &str, ) -> Result { let mut cmd = self.git_cmd(repo); cmd.arg("diff") .arg("--quiet") .arg("--no-renames") .arg(commit1) .arg(commit2); self.check(&mut cmd).await } async fn get_author(&self, repo: &Repository, commit: &str) -> Result { self.get_log_format(repo, commit, "%an%x00%al%x00%ae") .map_ok(parse_user) .await } async fn get_commiter(&self, repo: &Repository, commit: &str) -> Result { self.get_log_format(repo, commit, "%cn%x00%cl%x00%ce") .map_ok(parse_user) .await } async fn delete_branch(&self, repo: &Repository, branch: &str) -> Result<(), Error> { let mut cmd = self.git_cmd(repo); cmd.arg("branch").arg("--delete").arg("--force").arg(branch); self.run(&mut cmd).await } async fn ls_tree( &self, repo: &Repository, commit: &str, recursive: bool, ) -> Result, Error> { let mut cmd = self.git_cmd(repo); cmd.arg("ls-tree").arg("-z"); if recursive { cmd.arg("-r"); } cmd.arg(commit); self.output(&mut cmd).map_ok(parse_tree_entries).await } async fn get_log_format( &self, repo: &Repository, commit: &str, format: &str, ) -> Result { let mut cmd = self.git_cmd(repo); cmd.arg("log") .arg("-1") .arg("--no-decorate") .arg("--no-mailmap") .arg(format!("--pretty=format:{format}")) .arg(commit); self.output(&mut cmd).await } fn git_cmd(&self, repo: &Repository) -> Command { let mut cmd = Command::new("git"); // Run as if git was started in instead of the current working directory. cmd.arg("-C").arg(repo.path().to_str().unwrap()); // Disable all advice hints from being printed. cmd.arg("--no-advice"); // Do not pipe Git output into a pager. cmd.arg("--no-pager"); // Do not perform optional operations that require locks. cmd.arg("--no-optional-locks"); cmd } async fn run(&self, cmd: &mut Command) -> Result<(), Error> { cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()); let child = cmd .spawn() .map_err(|e| Error::new(format!("git command failed to start: {e}")))?; let output = child .wait_with_output() .map_err(|e| Error::new(format!("git command failed to execute: {e}"))) .await?; if output.status.success() { Ok(()) } else { Err(Error::new(format!( "git command failed with exitcode: {}\n{:?}\n{}", output.status, cmd.as_std().get_args(), std::str::from_utf8(output.stderr.as_slice()).unwrap_or(""), ))) } } async fn check(&self, cmd: &mut Command) -> Result { cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()); let child = cmd .spawn() .map_err(|e| Error::new(format!("git command failed to start: {e}")))?; let output = child .wait_with_output() .map_err(|e| Error::new(format!("git command failed to execute: {e}"))) .await?; if output.status.success() { Ok(true) } else { match output.status.code() { Some(1) => Ok(false), _ => Err(Error::new(format!( "git command failed with exitcode: {}\n{:?}\n{}", output.status, cmd.as_std().get_args(), std::str::from_utf8(output.stderr.as_slice()).unwrap_or(""), ))), } } } async fn output(&self, cmd: &mut Command) -> Result { cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let child = cmd .spawn() .map_err(|e| Error::new(format!("git command failed to start: {e}")))?; let output = child .wait_with_output() .map_err(|e| Error::new(format!("git command failed to execute: {e}"))) .await?; if output.status.success() { let output_utf8 = String::from_utf8(output.stdout) .map_err(|e| Error::new(format!("git command had invalid output: {e}")))?; Ok(output_utf8) } else { Err(Error::new(format!( "git command failed with exitcode: {}\n{:?}\n{}", output.status, cmd.as_std().get_args(), std::str::from_utf8(output.stderr.as_slice()).unwrap_or(""), ))) } } } #[allow(dead_code)] impl Repository { pub fn new( path: impl Into, bare: bool, remote: Option>, project_id: Option>, githook: Option>, ssh_config: Option>, ) -> Self { let path = path.into(); let project_id = project_id.map(|x| x.into()); let githook = githook.map(|x| x.into()); let ssh_config = ssh_config.map(|x| x.into()); let socket: Option; if let Some(project_id) = &project_id { socket = Some( path.parent() .unwrap() .join(format!("{}.socket", project_id)), ); } else { socket = None; } Self { remote: remote.map(|x| x.into()), project_id, path, socket, githook, ssh_config, bare, lock: RwLock::new(RepoData::new()), } } pub fn remote(&self) -> Option<&str> { self.remote.as_deref() } pub fn project_id(&self) -> Option<&str> { self.project_id.as_deref() } pub fn path(&self) -> &Path { self.path.as_path() } pub fn socket(&self) -> Option<&Path> { self.socket.as_deref() } fn githook(&self) -> Option<&Path> { self.githook.as_deref() } pub fn ssh_config(&self) -> Option<&Path> { self.ssh_config.as_deref() } pub fn is_bare(&self) -> bool { self.bare } pub async fn setup(&self) -> Result<(), Error> { let mut data = self.lock.write().await; data.init(self).await?; data.sync_config(self).await?; if self.socket.is_some() { data.sync_hooks(self).await?; } Ok(()) } pub async fn fetch(&self, branch: impl Into) -> Result<(), Error> { let branch = branch.into(); let data = self.lock.read().await; data.fetch(self, branch).await } pub async fn config_get(&self, name: impl Into) -> Result { let name = name.into(); let data = self.lock.read().await; data.config_get(self, name.as_str()).await } pub async fn is_ancestor( &self, ancestor: impl Into, commit: impl Into, ) -> Result { let ancestor = ancestor.into(); let commit = commit.into(); let data = self.lock.read().await; data.is_ancestor(self, ancestor.as_str(), commit.as_str()) .await } pub async fn is_equal_content( &self, commit1: impl Into, commit2: impl Into, ) -> Result { let commit1 = commit1.into(); let commit2 = commit2.into(); let data = self.lock.read().await; data.is_equal_content(self, commit1.as_str(), commit2.as_str()) .await } pub async fn get_author(&self, commit: impl Into) -> Result { let commit = commit.into(); let data = self.lock.read().await; data.get_author(self, commit.as_str()).await } pub async fn get_commiter(&self, commit: impl Into) -> Result { let commit = commit.into(); let data = self.lock.read().await; data.get_commiter(self, commit.as_str()).await } pub async fn delete_branch(&self, branch: impl Into) -> Result<(), Error> { let branch = branch.into(); let data = self.lock.read().await; data.delete_branch(self, branch.as_str()).await } pub async fn ls_tree( &self, commit: impl Into, recursive: bool, ) -> Result, Error> { let commit = commit.into(); let data = self.lock.read().await; data.ls_tree(self, commit.as_str(), recursive).await } }