summaryrefslogtreecommitdiff
path: root/server/src/git.rs
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/git.rs')
-rw-r--r--server/src/git.rs451
1 files changed, 451 insertions, 0 deletions
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<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 {}
+
+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>,
+
+ // 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>,
+}
+
+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><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?;
+ }
+ 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<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
+ }
+
+ 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: {}",
+ output.status
+ )))
+ }
+ }
+}
+
+#[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>>,
+ ) -> Self {
+ let path = path.into();
+ let project_id = project_id.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,
+ 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<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
+ }
+}