diff options
| author | Joel Klinghed <the_jk@spawned.biz> | 2025-02-01 22:42:11 +0100 |
|---|---|---|
| committer | Joel Klinghed <the_jk@spawned.biz> | 2025-02-01 22:42:11 +0100 |
| commit | d780391408b9e6d443e5e4f907748cae484b79fb (patch) | |
| tree | d961efd62478248081d1a327c818d6fa171f0a2d /server/common | |
| parent | 05b674190f26e2a58cc7b7288586c031552d50f3 (diff) | |
Use workspace instead of features
Having to include --feature=build-server in basically all commands
that wasn't building eyeballs-githook got tiring quickly.
Instead, use workspaces, with a separate project for building
the githook. It means I also had to add a library common with
code shared by both githook and server.
Diffstat (limited to 'server/common')
| -rw-r--r-- | server/common/Cargo.toml | 10 | ||||
| -rw-r--r-- | server/common/src/fs_utils.rs | 54 | ||||
| -rw-r--r-- | server/common/src/git.rs | 539 | ||||
| -rw-r--r-- | server/common/src/git_socket.rs | 24 | ||||
| -rw-r--r-- | server/common/src/lib.rs | 3 |
5 files changed, 630 insertions, 0 deletions
diff --git a/server/common/Cargo.toml b/server/common/Cargo.toml new file mode 100644 index 0000000..a17fb95 --- /dev/null +++ b/server/common/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "eyeballs-common" +version = "0.1.0" +edition = "2021" + +[dependencies] +futures.workspace = true +pathdiff = "0.2.3" +serde.workspace = true +tokio = { workspace = true, features = ["fs", "process", "sync"] } diff --git a/server/common/src/fs_utils.rs b/server/common/src/fs_utils.rs new file mode 100644 index 0000000..7905d01 --- /dev/null +++ b/server/common/src/fs_utils.rs @@ -0,0 +1,54 @@ +#![allow(dead_code)] + +use std::io; +use std::path::Path; +use tokio::fs; + +pub async fn create_dir_allow_existing(path: impl AsRef<Path>) -> io::Result<()> { + match fs::create_dir(path).await { + Ok(_) => Ok(()), + Err(e) => { + if e.kind() == io::ErrorKind::AlreadyExists { + Ok(()) + } else { + Err(e) + } + } + } +} + +pub async fn remove_file_allow_not_found(path: impl AsRef<Path>) -> io::Result<()> { + match fs::remove_file(path).await { + Ok(_) => Ok(()), + Err(e) => { + if e.kind() == io::ErrorKind::NotFound { + Ok(()) + } else { + Err(e) + } + } + } +} + +pub async fn symlink_update_existing( + src: impl AsRef<Path>, + dst: impl AsRef<Path>, +) -> io::Result<()> { + let src = src.as_ref(); + let dst = dst.as_ref(); + match fs::symlink(&src, &dst).await { + Ok(_) => Ok(()), + Err(e) => { + if e.kind() == io::ErrorKind::AlreadyExists { + let path = fs::read_link(&dst).await?; + if path == src { + return Ok(()); + } + fs::remove_file(&dst).await?; + fs::symlink(&src, &dst).await + } else { + Err(e) + } + } + } +} diff --git a/server/common/src/git.rs b/server/common/src/git.rs new file mode 100644 index 0000000..e2966e0 --- /dev/null +++ b/server/common/src/git.rs @@ -0,0 +1,539 @@ +#![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<String>) -> 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<String, String>, +} + +pub struct Repository { + path: PathBuf, + bare: bool, + + remote: Option<String>, + project_id: Option<String>, + socket: Option<PathBuf>, + githook: Option<PathBuf>, + + // Lock for any repo task, 90% of all tasks are readers but there are some writers + // where nothing else may be done. + lock: RwLock<RepoData>, +} + +#[allow(dead_code)] +pub struct User { + pub name: String, + pub email: String, + // Part before '@' in email + pub username: 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.lines(); + 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, + } +} + +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><remote branch>:<local branch> + 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?; + } + + // 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 + } + + async fn config_get(&self, repo: &Repository, name: &str) -> Result<String, Error> { + 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<bool, Error> { + 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<bool, Error> { + 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<User, Error> { + self.get_log_format(repo, commit, "%an%n%al%n%ae") + .map_ok(parse_user) + .await + } + + async fn get_commiter(&self, repo: &Repository, commit: &str) -> Result<User, Error> { + self.get_log_format(repo, commit, "%cn%n%cl%n%ce") + .map_ok(parse_user) + .await + } + + async fn get_log_format( + &self, + repo: &Repository, + commit: &str, + format: &str, + ) -> Result<String, Error> { + 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 <path> 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<bool, 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(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<String, Error> { + 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<PathBuf>, + bare: bool, + remote: Option<impl Into<String>>, + project_id: Option<impl Into<String>>, + githook: Option<impl Into<PathBuf>>, + ) -> Self { + let path = path.into(); + let project_id = project_id.map(|x| x.into()); + let githook = githook.map(|x| x.into()); + let socket: Option<PathBuf>; + 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, + 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 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<String>) -> 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<String>) -> Result<String, Error> { + 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<String>, + commit: impl Into<String>, + ) -> Result<bool, Error> { + 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<String>, + commit2: impl Into<String>, + ) -> Result<bool, Error> { + 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<String>) -> Result<User, Error> { + 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<String>) -> Result<User, Error> { + let commit = commit.into(); + let data = self.lock.read().await; + + data.get_commiter(self, commit.as_str()).await + } +} diff --git a/server/common/src/git_socket.rs b/server/common/src/git_socket.rs new file mode 100644 index 0000000..a4805be --- /dev/null +++ b/server/common/src/git_socket.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +pub struct GitReceive { + pub old_value: String, + pub new_value: String, + pub reference: String, + // Only set for pre hooks, because server can't read the objects the pre-hook has not yet + // accepted, so to be able to validate the commiter, send them. Also only set if new_value + // is not empty. + pub commiter: Option<String>, +} + +#[derive(Deserialize, Serialize)] +pub struct GitHookRequest { + pub pre: bool, + pub receive: Vec<GitReceive>, +} + +#[derive(Deserialize, Serialize)] +pub struct GitHookResponse { + pub ok: bool, + pub message: String, +} diff --git a/server/common/src/lib.rs b/server/common/src/lib.rs new file mode 100644 index 0000000..a63e05b --- /dev/null +++ b/server/common/src/lib.rs @@ -0,0 +1,3 @@ +pub mod fs_utils; +pub mod git; +pub mod git_socket; |
