summaryrefslogtreecommitdiff
path: root/server/common/src
diff options
context:
space:
mode:
authorJoel Klinghed <the_jk@spawned.biz>2025-02-01 22:42:11 +0100
committerJoel Klinghed <the_jk@spawned.biz>2025-02-01 22:42:11 +0100
commitd780391408b9e6d443e5e4f907748cae484b79fb (patch)
treed961efd62478248081d1a327c818d6fa171f0a2d /server/common/src
parent05b674190f26e2a58cc7b7288586c031552d50f3 (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/src')
-rw-r--r--server/common/src/fs_utils.rs54
-rw-r--r--server/common/src/git.rs539
-rw-r--r--server/common/src/git_socket.rs24
-rw-r--r--server/common/src/lib.rs3
4 files changed, 620 insertions, 0 deletions
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;