diff --git a/.gitignore b/.gitignore index 3a8cabc..4f96098 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .idea +.env diff --git a/Cargo.lock b/Cargo.lock index bd4ca74..c9f198f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,12 @@ dependencies = [ ] [[package]] +name = "anyhow" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" + +[[package]] name = "async-trait" version = "0.1.73" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -518,6 +524,7 @@ dependencies = [ name = "giterated-daemon" version = "0.1.0" dependencies = [ + "anyhow", "async-trait", "chrono", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 685cbbd..40cbe69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ async-trait = "0.1" # Git backend git2 = "0.17" thiserror = "1" +anyhow = "1" sqlx = { version = "0.7", features = [ "runtime-tokio", "tls-native-tls", "postgres", "macros", "migrate", "chrono" ] } #uuid = { version = "1.4", features = [ "v4", "serde" ] } diff --git a/migrations/20230828083716_create_repositories.sql b/migrations/20230828083716_create_repositories.sql new file mode 100644 index 0000000..698612d --- /dev/null +++ b/migrations/20230828083716_create_repositories.sql @@ -0,0 +1,18 @@ +CREATE TYPE visibility AS ENUM +( + 'public', + 'unlisted', + 'private' +); + +CREATE TABLE IF NOT EXISTS repositories +( + username TEXT NOT NULL, + instance_url TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + visibility visibility NOT NULL, + default_branch TEXT NOT NULL +); + +CREATE UNIQUE INDEX unique_name_per_user ON repositories (username, instance_url, name); diff --git a/src/backend/git.rs b/src/backend/git.rs index 6fadbe5..b2b47c7 100644 --- a/src/backend/git.rs +++ b/src/backend/git.rs @@ -1,9 +1,13 @@ -use std::error::Error; -use std::path::PathBuf; use async_trait::async_trait; -use git2::{Repository, TreeEntry}; +use git2::ObjectType; use sqlx::PgPool; +use std::error::Error; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use crate::model::repository::{ + Commit, RepositoryObjectType, RepositoryTreeEntry, RepositoryVisibility, +}; use crate::{ messages::repository::{ CreateRepositoryRequest, CreateRepositoryResponse, RepositoryFileInspectRequest, @@ -13,7 +17,6 @@ use crate::{ }, model::repository::RepositoryView, }; -use crate::model::repository::{RepositoryTreeEntry, RepositoryVisibility}; use super::{IssuesBackend, RepositoryBackend}; @@ -36,7 +39,7 @@ impl GitRepository { /// Checks if the user is allowed to view this repository pub fn can_user_view_repository(&self, instance_url: &str, username: Option<&str>) -> bool { !(matches!(self.visibility, RepositoryVisibility::Private) - && self.instance_url != instance_url.map_or("", |instance_url| instance_url) + && self.instance_url != instance_url && self.username != username.map_or("", |username| username)) } @@ -89,7 +92,9 @@ pub enum GitBackendError { #[error("Couldn't find ref with name `{0}`")] RefNotFound(String), #[error("Couldn't find path in repository `{0}`")] - PathNotFound(String) + PathNotFound(String), + #[error("Couldn't find commit for path `{0}`")] + LastCommitNotFound(String), } pub struct GitBackend { @@ -98,8 +103,11 @@ pub struct GitBackend { } impl GitBackend { - pub fn new() -> Self { - Self + pub fn new(pg_pool: &PgPool, repository_folder: &str) -> Self { + Self { + pg_pool: pg_pool.clone(), + repository_folder: repository_folder.to_string(), + } } pub async fn find_by_instance_username_name( @@ -109,9 +117,9 @@ impl GitBackend { repository_name: &str, ) -> Result { if let Ok(repository) = sqlx::query_as!(GitRepository, - r#"SELECT username, instance_url, name, description, visibility as "visibility: _", default_branch FROM repositories WHERE instance_url = $1 AND username = $2 AND name = $3"# + r#"SELECT username, instance_url, name, description, visibility as "visibility: _", default_branch FROM repositories WHERE instance_url = $1 AND username = $2 AND name = $3"#, instance_url, username, repository_name) - .fetch_one(self.pg_pool.clone()) + .fetch_one(&self.pg_pool.clone()) .await { Ok(repository) } else { @@ -144,17 +152,60 @@ impl GitBackend { // Delete the repository from the database return match sqlx::query!( - "DELETE FROM repositories WHERE instance_url = $1, username = $2 AND name = $3", + "DELETE FROM repositories WHERE instance_url = $1 AND username = $2 AND name = $3", instance_url, username, repository_name ) - .execute(self.pg_pool.clone()) - .await { + .execute(&self.pg_pool.clone()) + .await + { Ok(deleted) => Ok(deleted.rows_affected()), - Err(err) => Err(GitBackendError::FailedDeletingFromDatabase(err)) + Err(err) => Err(GitBackendError::FailedDeletingFromDatabase(err)), }; } + + // TODO: Find where this fits + // TODO: Cache this and general repository tree and invalidate select files on push + // TODO: Find better and faster technique for this + pub fn get_last_commit_of_file( + path: &str, + git: &git2::Repository, + start_commit: &git2::Commit, + ) -> anyhow::Result { + let mut revwalk = git.revwalk()?; + revwalk.set_sorting(git2::Sort::TIME)?; + revwalk.push(start_commit.id())?; + + for oid in revwalk { + let oid = oid?; + let commit = git.find_commit(oid)?; + + // Merge commits have 2 or more parents + // Commits with 0 parents are handled different because we can't diff against them + if commit.parent_count() == 0 { + return Ok(commit.into()); + } else if commit.parent_count() == 1 { + let tree = commit.tree()?; + let last_tree = commit.parent(0)?.tree()?; + + // Get the diff between the current tree and the last one + let diff = git.diff_tree_to_tree(Some(&last_tree), Some(&tree), None)?; + + for dd in diff.deltas() { + // Get the path of the current file we're diffing against + let current_path = dd.new_file().path().unwrap(); + + // Path or directory + if current_path.eq(Path::new(&path)) || current_path.starts_with(&path) { + return Ok(commit.into()); + } + } + } + } + + Err(GitBackendError::LastCommitNotFound(path.to_string()))? + } } #[async_trait] @@ -164,8 +215,13 @@ impl RepositoryBackend for GitBackend { request: &CreateRepositoryRequest, ) -> Result> { // Check if repository already exists in the database - if let Ok(repository) = - self.find_by_instance_username_name(request.owner.instance.url.as_str(), request.owner.username.as_str(), request.name.as_str()).await + if let Ok(repository) = self + .find_by_instance_username_name( + request.owner.instance.url.as_str(), + request.owner.username.as_str(), + request.name.as_str(), + ) + .await { let err = GitBackendError::RepositoryAlreadyExists { instance_url: request.owner.instance.url.clone(), @@ -181,7 +237,7 @@ impl RepositoryBackend for GitBackend { let _ = match sqlx::query_as!(GitRepository, r#"INSERT INTO repositories VALUES ($1, $2, $3, $4, $5, $6) RETURNING username, instance_url, name, description, visibility as "visibility: _", default_branch"#, request.owner.username, request.owner.instance.url, request.name, request.description, request.visibility as _, "master") - .fetch_one(self.pg_pool.clone()) + .fetch_one(&self.pg_pool.clone()) .await { Ok(repository) => repository, Err(err) => { @@ -195,7 +251,10 @@ impl RepositoryBackend for GitBackend { // Create bare (server side) repository on disk match git2::Repository::init_bare(PathBuf::from(format!( "{}/{}/{}/{}", - self.repository_folder, request.owner.instance.url, request.owner.username, request.name + self.repository_folder, + request.owner.instance.url, + request.owner.username, + request.name ))) { Ok(_) => { debug!( @@ -209,9 +268,16 @@ impl RepositoryBackend for GitBackend { error!("Failed creating repository on disk!? {:?}", err); // Delete repository from database - self.delete_by_instance_username_name( - request.owner.instance.url.as_str(), request.owner.username.as_str(), request.name.as_str() - ).await?; + if let Err(err) = self + .delete_by_instance_username_name( + request.owner.instance.url.as_str(), + request.owner.username.as_str(), + request.name.as_str(), + ) + .await + { + return Err(Box::new(err)); + } // ??? Ok(CreateRepositoryResponse::Failed) @@ -224,9 +290,22 @@ impl RepositoryBackend for GitBackend { &mut self, request: &RepositoryInfoRequest, ) -> Result> { - let repository = self.find_by_instance_username_name(&request.owner.instance.url, &request.owner.username, &request.name).await?; + let repository = match self + .find_by_instance_username_name( + &request.owner.instance.url, + &request.owner.username, + &request.name, + ) + .await + { + Ok(repository) => repository, + Err(err) => return Err(Box::new(err)), + }; - if !repository.can_user_view_repository(request.owner.instance.url.as_str(), Some(&request.owner.username.as_str())) { + if !repository.can_user_view_repository( + request.owner.instance.url.as_str(), + Some(&request.owner.username.as_str()), + ) { return Err(Box::new(GitBackendError::RepositoryNotFound { instance_url: request.owner.instance.url.clone(), username: request.owner.username.clone(), @@ -236,7 +315,7 @@ impl RepositoryBackend for GitBackend { let git = match repository.open_git2_repository(&self.repository_folder) { Ok(git) => git, - Err(err) => return Err(Box::new(err)) + Err(err) => return Err(Box::new(err)), }; let rev_name = match &request.rev { @@ -251,24 +330,31 @@ impl RepositoryBackend for GitBackend { visibility: repository.visibility, default_branch: repository.default_branch, latest_commit: None, + tree_rev: None, tree: vec![], }); } } Some(rev_name) => { // Find the reference, otherwise return GitBackendError - git.find_reference(format!("refs/heads/{}", rev_name).as_str()) - .map_err(|_| GitBackendError::RefNotFound(rev_name.to_string()))? - .name() - .unwrap() - .to_string() + match git + .find_reference(format!("refs/heads/{}", rev_name).as_str()) + .map_err(|_| GitBackendError::RefNotFound(rev_name.to_string())) + { + Ok(reference) => reference.name().unwrap().to_string(), + Err(err) => return Err(Box::new(err)), + } } }; // Get the git object as a commit - let rev = git + let rev = match git .revparse_single(rev_name.as_str()) - .map_err(|_| GitBackendError::RefNotFound(rev_name.to_string()))?; + .map_err(|_| GitBackendError::RefNotFound(rev_name.to_string())) + { + Ok(rev) => rev, + Err(err) => return Err(Box::new(err)), + }; let commit = rev.as_commit().unwrap(); // this is stupid @@ -283,9 +369,10 @@ impl RepositoryBackend for GitBackend { .tree() .unwrap() .get_path(&PathBuf::from(path)) - .map_err(|_| GitBackendError::PathNotFound(path.to_string())) { + .map_err(|_| GitBackendError::PathNotFound(path.to_string())) + { Ok(entry) => entry, - Err(err) => return Err(Box::new(err)) + Err(err) => return Err(Box::new(err)), }; // Turn the entry into a git tree entry.to_object(&git).unwrap().as_tree().unwrap().clone() @@ -294,9 +381,57 @@ impl RepositoryBackend for GitBackend { }; // Iterate over the git tree and collect it into our own tree types - let tree = git_tree.iter().map(|entry| { - let mut tree_entry = RepositoryTreeEntry::new(entry.name().unwrap(), entry.kind().unwrap(), entry.filemode()); - // TODO: working on this currently + let mut tree = git_tree + .iter() + .map(|entry| { + let object_type = match entry.kind().unwrap() { + ObjectType::Tree => RepositoryObjectType::Tree, + ObjectType::Blob => RepositoryObjectType::Blob, + _ => unreachable!(), + }; + let mut tree_entry = + RepositoryTreeEntry::new(entry.name().unwrap(), object_type, entry.filemode()); + + if request.extra_metadata { + // Get the file size if It's a blob + let object = entry.to_object(&git).unwrap(); + if let Some(blob) = object.as_blob() { + tree_entry.size = Some(blob.size()); + } + + // Could possibly be done better + let path = if let Some(path) = current_path.split_once("/") { + format!("{}/{}", path.1, entry.name().unwrap()) + } else { + entry.name().unwrap().to_string() + }; + + // Get the last commit made to the entry + if let Ok(last_commit) = + GitBackend::get_last_commit_of_file(&path, &git, commit) + { + tree_entry.last_commit = Some(last_commit); + } + } + + tree_entry + }) + .collect::>(); + + // Sort the tree alphabetically and with tree first + tree.sort_unstable_by_key(|entry| entry.name.to_lowercase()); + tree.sort_unstable_by_key(|entry| { + std::cmp::Reverse(format!("{:?}", entry.object_type).to_lowercase()) + }); + + Ok(RepositoryView { + name: repository.name, + description: repository.description, + visibility: repository.visibility, + default_branch: repository.default_branch, + latest_commit: None, + tree_rev: Some(rev_name), + tree, }) } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 88c0416..c082568 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -1,8 +1,8 @@ pub mod git; pub mod github; -use std::error::Error; use async_trait::async_trait; +use std::error::Error; use crate::{ messages::{ diff --git a/src/messages/repository.rs b/src/messages/repository.rs index 7c1c55a..6a14064 100644 --- a/src/messages/repository.rs +++ b/src/messages/repository.rs @@ -1,10 +1,10 @@ use serde::{Deserialize, Serialize}; +use crate::model::repository::RepositoryVisibility; use crate::model::{ repository::{Commit, Repository, RepositoryTreeEntry, RepositoryView}, user::User, }; -use crate::model::repository::RepositoryVisibility; #[derive(Clone, Serialize, Deserialize)] pub struct RepositoryMessage { diff --git a/src/model/repository.rs b/src/model/repository.rs index 74bfdc3..fb4cf65 100644 --- a/src/model/repository.rs +++ b/src/model/repository.rs @@ -15,7 +15,8 @@ pub struct Repository { } /// Visibility of the repository to the general eye -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Hash, Serialize, Deserialize, Clone, sqlx::Type)] +#[sqlx(type_name = "visibility", rename_all = "lowercase")] pub enum RepositoryVisibility { Public, Unlisted, @@ -34,33 +35,46 @@ pub struct RepositoryView { pub default_branch: String, /// Last commit made to the repository pub latest_commit: Option, + /// Revision of the displayed tree + pub tree_rev: Option, /// Repository tree pub tree: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub enum RepositoryTreeEntry { - Tree(RepositoryTreeEntryInfo), - Blob(RepositoryTreeEntryInfo), -} - -impl RepositoryTreeEntry { - // I love you Emilia <3 +pub enum RepositoryObjectType { + Tree, + Blob, } /// Stored info for our tree entries #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RepositoryTreeEntryInfo { +pub struct RepositoryTreeEntry { /// Name of the tree/blob pub name: String, + /// Type of the tree entry + pub object_type: RepositoryObjectType, /// Git supplies us with the mode at all times, and people like it displayed. pub mode: i32, /// File size - pub blob_size: Option, + pub size: Option, /// Last commit made to the tree/blob pub last_commit: Option, } +impl RepositoryTreeEntry { + // I love you Emilia <3 + pub fn new(name: &str, object_type: RepositoryObjectType, mode: i32) -> Self { + Self { + name: name.to_string(), + object_type, + mode, + size: None, + last_commit: None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RepositoryTreeEntryWithCommit { pub tree_entry: RepositoryTreeEntry, @@ -82,6 +96,21 @@ pub struct Commit { pub time: chrono::NaiveDateTime, } +/// Gets all info from [`git2::Commit`] for easy use +impl From> for Commit { + fn from(commit: git2::Commit<'_>) -> Self { + Self { + oid: commit.id().to_string(), + message: commit + .message() + .map_or(None, |message| Some(message.to_string())), + author: commit.author().into(), + committer: commit.committer().into(), + time: chrono::NaiveDateTime::from_timestamp_opt(commit.time().seconds(), 0).unwrap(), + } + } +} + /// Git commit signature #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommitSignature { @@ -89,3 +118,16 @@ pub struct CommitSignature { pub email: Option, pub time: chrono::NaiveDateTime, } + +/// Converts the signature from git2 into something usable without explicit lifetimes. +impl From> for CommitSignature { + fn from(signature: git2::Signature<'_>) -> Self { + Self { + name: signature.name().map_or(None, |name| Some(name.to_string())), + email: signature + .email() + .map_or(None, |email| Some(email.to_string())), + time: chrono::NaiveDateTime::from_timestamp_opt(signature.when().seconds(), 0).unwrap(), + } + } +}