From 9e80b8cd1e44fcf863d926055d9fa458db46e0d3 Mon Sep 17 00:00:00 2001 From: Joel Klinghed Date: Sun, 26 Jan 2025 21:58:42 +0100 Subject: Add basic git support Pushing a commit to a new branch creates a review. Each project has its own git directory, with githooks installed that talkes with server process via unix sockets. --- server/src/git.rs | 451 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 451 insertions(+) create mode 100644 server/src/git.rs (limited to 'server/src/git.rs') diff --git a/server/src/git.rs b/server/src/git.rs new file mode 100644 index 0000000..652eb29 --- /dev/null +++ b/server/src/git.rs @@ -0,0 +1,451 @@ +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 {} + +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, + + // Lock for any repo task, 90% of all tasks are readers but there are some writers + // where nothing else may be done. + lock: RwLock, +} + +fn io_err(action: &str, e: std::io::Error) -> Error { + Error::new(format!("{action}: {e}")) +} + +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?; + } + Ok(()) + } + + async fn sync_hooks(&mut self, repo: &Repository) -> Result<(), Error> { + let server_exe = + std::env::current_exe().map_err(|e| io_err("unable to get current exe", e))?; + + let hook = 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 + } + + async fn config_get(&self, repo: &Repository, name: &str) -> Result { + 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> { + 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.to_string(), 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 + } + + 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: {}", + output.status + ))) + } + } +} + +#[allow(dead_code)] +impl Repository { + pub fn new( + path: impl Into, + bare: bool, + remote: Option>, + project_id: Option>, + ) -> Self { + let path = path.into(); + let project_id = project_id.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, + 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() + } + + 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 + } +} -- cgit v1.2.3-70-g09d2