JavaScript is disabled, refresh for a better experience. ambee/giterated

ambee/giterated

Git repository hosting, collaboration, and discovery for the Fediverse.

Initial git handling

Type: Feature

emilia - ⁨2⁩ years ago

parent: tbd commit: ⁨523decf

Showing ⁨⁨8⁩ changed files⁩ with ⁨⁨252⁩ insertions⁩ and ⁨⁨48⁩ deletions⁩

.gitignore

View file
@@ -1,2 +1,3 @@
1 1 /target
2 2 .idea
3 .env

Cargo.lock

View file
@@ -51,6 +51,12 @@ dependencies = [
51 51 ]
52 52
53 53 [[package]]
54 name = "anyhow"
55 version = "1.0.75"
56 source = "registry+https://github.com/rust-lang/crates.io-index"
57 checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
58
59 [[package]]
54 60 name = "async-trait"
55 61 version = "0.1.73"
56 62 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -518,6 +524,7 @@ dependencies = [
518 524 name = "giterated-daemon"
519 525 version = "0.1.0"
520 526 dependencies = [
527 "anyhow",
521 528 "async-trait",
522 529 "chrono",
523 530 "futures-util",

Cargo.toml

View file
@@ -20,6 +20,7 @@ async-trait = "0.1"
20 20 # Git backend
21 21 git2 = "0.17"
22 22 thiserror = "1"
23 anyhow = "1"
23 24 sqlx = { version = "0.7", features = [ "runtime-tokio", "tls-native-tls", "postgres", "macros", "migrate", "chrono" ] }
24 25
25 26 #uuid = { version = "1.4", features = [ "v4", "serde" ] }

migrations/20230828083716_create_repositories.sql

View file
@@ -0,0 +1,18 @@
1 CREATE TYPE visibility AS ENUM
2 (
3 'public',
4 'unlisted',
5 'private'
6 );
7
8 CREATE TABLE IF NOT EXISTS repositories
9 (
10 username TEXT NOT NULL,
11 instance_url TEXT NOT NULL,
12 name TEXT NOT NULL,
13 description TEXT,
14 visibility visibility NOT NULL,
15 default_branch TEXT NOT NULL
16 );
17
18 CREATE UNIQUE INDEX unique_name_per_user ON repositories (username, instance_url, name);

src/backend/git.rs

View file
@@ -1,9 +1,13 @@
1 use std::error::Error;
2 use std::path::PathBuf;
3 1 use async_trait::async_trait;
4 use git2::{Repository, TreeEntry};
2 use git2::ObjectType;
5 3 use sqlx::PgPool;
4 use std::error::Error;
5 use std::path::{Path, PathBuf};
6 use thiserror::Error;
6 7
8 use crate::model::repository::{
9 Commit, RepositoryObjectType, RepositoryTreeEntry, RepositoryVisibility,
10 };
7 11 use crate::{
8 12 messages::repository::{
9 13 CreateRepositoryRequest, CreateRepositoryResponse, RepositoryFileInspectRequest,
@@ -13,7 +17,6 @@ use crate::{
13 17 },
14 18 model::repository::RepositoryView,
15 19 };
16 use crate::model::repository::{RepositoryTreeEntry, RepositoryVisibility};
17 20
18 21 use super::{IssuesBackend, RepositoryBackend};
19 22
@@ -36,7 +39,7 @@ impl GitRepository {
36 39 /// Checks if the user is allowed to view this repository
37 40 pub fn can_user_view_repository(&self, instance_url: &str, username: Option<&str>) -> bool {
38 41 !(matches!(self.visibility, RepositoryVisibility::Private)
39 && self.instance_url != instance_url.map_or("", |instance_url| instance_url)
42 && self.instance_url != instance_url
40 43 && self.username != username.map_or("", |username| username))
41 44 }
42 45
@@ -89,7 +92,9 @@ pub enum GitBackendError {
89 92 #[error("Couldn't find ref with name `{0}`")]
90 93 RefNotFound(String),
91 94 #[error("Couldn't find path in repository `{0}`")]
92 PathNotFound(String)
95 PathNotFound(String),
96 #[error("Couldn't find commit for path `{0}`")]
97 LastCommitNotFound(String),
93 98 }
94 99
95 100 pub struct GitBackend {
@@ -98,8 +103,11 @@ pub struct GitBackend {
98 103 }
99 104
100 105 impl GitBackend {
101 pub fn new() -> Self {
102 Self
106 pub fn new(pg_pool: &PgPool, repository_folder: &str) -> Self {
107 Self {
108 pg_pool: pg_pool.clone(),
109 repository_folder: repository_folder.to_string(),
110 }
103 111 }
104 112
105 113 pub async fn find_by_instance_username_name(
@@ -109,9 +117,9 @@ impl GitBackend {
109 117 repository_name: &str,
110 118 ) -> Result<GitRepository, GitBackendError> {
111 119 if let Ok(repository) = sqlx::query_as!(GitRepository,
112 r#"SELECT username, instance_url, name, description, visibility as "visibility: _", default_branch FROM repositories WHERE instance_url = $1 AND username = $2 AND name = $3"#
120 r#"SELECT username, instance_url, name, description, visibility as "visibility: _", default_branch FROM repositories WHERE instance_url = $1 AND username = $2 AND name = $3"#,
113 121 instance_url, username, repository_name)
114 .fetch_one(self.pg_pool.clone())
122 .fetch_one(&self.pg_pool.clone())
115 123 .await {
116 124 Ok(repository)
117 125 } else {
@@ -144,17 +152,60 @@ impl GitBackend {
144 152
145 153 // Delete the repository from the database
146 154 return match sqlx::query!(
147 "DELETE FROM repositories WHERE instance_url = $1, username = $2 AND name = $3",
155 "DELETE FROM repositories WHERE instance_url = $1 AND username = $2 AND name = $3",
148 156 instance_url,
149 157 username,
150 158 repository_name
151 159 )
152 .execute(self.pg_pool.clone())
153 .await {
160 .execute(&self.pg_pool.clone())
161 .await
162 {
154 163 Ok(deleted) => Ok(deleted.rows_affected()),
155 Err(err) => Err(GitBackendError::FailedDeletingFromDatabase(err))
164 Err(err) => Err(GitBackendError::FailedDeletingFromDatabase(err)),
156 165 };
157 166 }
167
168 // TODO: Find where this fits
169 // TODO: Cache this and general repository tree and invalidate select files on push
170 // TODO: Find better and faster technique for this
171 pub fn get_last_commit_of_file(
172 path: &str,
173 git: &git2::Repository,
174 start_commit: &git2::Commit,
175 ) -> anyhow::Result<Commit> {
176 let mut revwalk = git.revwalk()?;
177 revwalk.set_sorting(git2::Sort::TIME)?;
178 revwalk.push(start_commit.id())?;
179
180 for oid in revwalk {
181 let oid = oid?;
182 let commit = git.find_commit(oid)?;
183
184 // Merge commits have 2 or more parents
185 // Commits with 0 parents are handled different because we can't diff against them
186 if commit.parent_count() == 0 {
187 return Ok(commit.into());
188 } else if commit.parent_count() == 1 {
189 let tree = commit.tree()?;
190 let last_tree = commit.parent(0)?.tree()?;
191
192 // Get the diff between the current tree and the last one
193 let diff = git.diff_tree_to_tree(Some(&last_tree), Some(&tree), None)?;
194
195 for dd in diff.deltas() {
196 // Get the path of the current file we're diffing against
197 let current_path = dd.new_file().path().unwrap();
198
199 // Path or directory
200 if current_path.eq(Path::new(&path)) || current_path.starts_with(&path) {
201 return Ok(commit.into());
202 }
203 }
204 }
205 }
206
207 Err(GitBackendError::LastCommitNotFound(path.to_string()))?
208 }
158 209 }
159 210
160 211 #[async_trait]
@@ -164,8 +215,13 @@ impl RepositoryBackend for GitBackend {
164 215 request: &CreateRepositoryRequest,
165 216 ) -> Result<CreateRepositoryResponse, Box<dyn Error + Send>> {
166 217 // Check if repository already exists in the database
167 if let Ok(repository) =
168 self.find_by_instance_username_name(request.owner.instance.url.as_str(), request.owner.username.as_str(), request.name.as_str()).await
218 if let Ok(repository) = self
219 .find_by_instance_username_name(
220 request.owner.instance.url.as_str(),
221 request.owner.username.as_str(),
222 request.name.as_str(),
223 )
224 .await
169 225 {
170 226 let err = GitBackendError::RepositoryAlreadyExists {
171 227 instance_url: request.owner.instance.url.clone(),
@@ -181,7 +237,7 @@ impl RepositoryBackend for GitBackend {
181 237 let _ = match sqlx::query_as!(GitRepository,
182 238 r#"INSERT INTO repositories VALUES ($1, $2, $3, $4, $5, $6) RETURNING username, instance_url, name, description, visibility as "visibility: _", default_branch"#,
183 239 request.owner.username, request.owner.instance.url, request.name, request.description, request.visibility as _, "master")
184 .fetch_one(self.pg_pool.clone())
240 .fetch_one(&self.pg_pool.clone())
185 241 .await {
186 242 Ok(repository) => repository,
187 243 Err(err) => {
@@ -195,7 +251,10 @@ impl RepositoryBackend for GitBackend {
195 251 // Create bare (server side) repository on disk
196 252 match git2::Repository::init_bare(PathBuf::from(format!(
197 253 "{}/{}/{}/{}",
198 self.repository_folder, request.owner.instance.url, request.owner.username, request.name
254 self.repository_folder,
255 request.owner.instance.url,
256 request.owner.username,
257 request.name
199 258 ))) {
200 259 Ok(_) => {
201 260 debug!(
@@ -209,9 +268,16 @@ impl RepositoryBackend for GitBackend {
209 268 error!("Failed creating repository on disk!? {:?}", err);
210 269
211 270 // Delete repository from database
212 self.delete_by_instance_username_name(
213 request.owner.instance.url.as_str(), request.owner.username.as_str(), request.name.as_str()
214 ).await?;
271 if let Err(err) = self
272 .delete_by_instance_username_name(
273 request.owner.instance.url.as_str(),
274 request.owner.username.as_str(),
275 request.name.as_str(),
276 )
277 .await
278 {
279 return Err(Box::new(err));
280 }
215 281
216 282 // ???
217 283 Ok(CreateRepositoryResponse::Failed)
@@ -224,9 +290,22 @@ impl RepositoryBackend for GitBackend {
224 290 &mut self,
225 291 request: &RepositoryInfoRequest,
226 292 ) -> Result<RepositoryView, Box<dyn Error + Send>> {
227 let repository = self.find_by_instance_username_name(&request.owner.instance.url, &request.owner.username, &request.name).await?;
293 let repository = match self
294 .find_by_instance_username_name(
295 &request.owner.instance.url,
296 &request.owner.username,
297 &request.name,
298 )
299 .await
300 {
301 Ok(repository) => repository,
302 Err(err) => return Err(Box::new(err)),
303 };
228 304
229 if !repository.can_user_view_repository(request.owner.instance.url.as_str(), Some(&request.owner.username.as_str())) {
305 if !repository.can_user_view_repository(
306 request.owner.instance.url.as_str(),
307 Some(&request.owner.username.as_str()),
308 ) {
230 309 return Err(Box::new(GitBackendError::RepositoryNotFound {
231 310 instance_url: request.owner.instance.url.clone(),
232 311 username: request.owner.username.clone(),
@@ -236,7 +315,7 @@ impl RepositoryBackend for GitBackend {
236 315
237 316 let git = match repository.open_git2_repository(&self.repository_folder) {
238 317 Ok(git) => git,
239 Err(err) => return Err(Box::new(err))
318 Err(err) => return Err(Box::new(err)),
240 319 };
241 320
242 321 let rev_name = match &request.rev {
@@ -251,24 +330,31 @@ impl RepositoryBackend for GitBackend {
251 330 visibility: repository.visibility,
252 331 default_branch: repository.default_branch,
253 332 latest_commit: None,
333 tree_rev: None,
254 334 tree: vec![],
255 335 });
256 336 }
257 337 }
258 338 Some(rev_name) => {
259 339 // Find the reference, otherwise return GitBackendError
260 git.find_reference(format!("refs/heads/{}", rev_name).as_str())
261 .map_err(|_| GitBackendError::RefNotFound(rev_name.to_string()))?
262 .name()
263 .unwrap()
264 .to_string()
340 match git
341 .find_reference(format!("refs/heads/{}", rev_name).as_str())
342 .map_err(|_| GitBackendError::RefNotFound(rev_name.to_string()))
343 {
344 Ok(reference) => reference.name().unwrap().to_string(),
345 Err(err) => return Err(Box::new(err)),
346 }
265 347 }
266 348 };
267 349
268 350 // Get the git object as a commit
269 let rev = git
351 let rev = match git
270 352 .revparse_single(rev_name.as_str())
271 .map_err(|_| GitBackendError::RefNotFound(rev_name.to_string()))?;
353 .map_err(|_| GitBackendError::RefNotFound(rev_name.to_string()))
354 {
355 Ok(rev) => rev,
356 Err(err) => return Err(Box::new(err)),
357 };
272 358 let commit = rev.as_commit().unwrap();
273 359
274 360 // this is stupid
@@ -283,9 +369,10 @@ impl RepositoryBackend for GitBackend {
283 369 .tree()
284 370 .unwrap()
285 371 .get_path(&PathBuf::from(path))
286 .map_err(|_| GitBackendError::PathNotFound(path.to_string())) {
372 .map_err(|_| GitBackendError::PathNotFound(path.to_string()))
373 {
287 374 Ok(entry) => entry,
288 Err(err) => return Err(Box::new(err))
375 Err(err) => return Err(Box::new(err)),
289 376 };
290 377 // Turn the entry into a git tree
291 378 entry.to_object(&git).unwrap().as_tree().unwrap().clone()
@@ -294,9 +381,57 @@ impl RepositoryBackend for GitBackend {
294 381 };
295 382
296 383 // Iterate over the git tree and collect it into our own tree types
297 let tree = git_tree.iter().map(|entry| {
298 let mut tree_entry = RepositoryTreeEntry::new(entry.name().unwrap(), entry.kind().unwrap(), entry.filemode());
299 // TODO: working on this currently
384 let mut tree = git_tree
385 .iter()
386 .map(|entry| {
387 let object_type = match entry.kind().unwrap() {
388 ObjectType::Tree => RepositoryObjectType::Tree,
389 ObjectType::Blob => RepositoryObjectType::Blob,
390 _ => unreachable!(),
391 };
392 let mut tree_entry =
393 RepositoryTreeEntry::new(entry.name().unwrap(), object_type, entry.filemode());
394
395 if request.extra_metadata {
396 // Get the file size if It's a blob
397 let object = entry.to_object(&git).unwrap();
398 if let Some(blob) = object.as_blob() {
399 tree_entry.size = Some(blob.size());
400 }
401
402 // Could possibly be done better
403 let path = if let Some(path) = current_path.split_once("/") {
404 format!("{}/{}", path.1, entry.name().unwrap())
405 } else {
406 entry.name().unwrap().to_string()
407 };
408
409 // Get the last commit made to the entry
410 if let Ok(last_commit) =
411 GitBackend::get_last_commit_of_file(&path, &git, commit)
412 {
413 tree_entry.last_commit = Some(last_commit);
414 }
415 }
416
417 tree_entry
418 })
419 .collect::<Vec<RepositoryTreeEntry>>();
420
421 // Sort the tree alphabetically and with tree first
422 tree.sort_unstable_by_key(|entry| entry.name.to_lowercase());
423 tree.sort_unstable_by_key(|entry| {
424 std::cmp::Reverse(format!("{:?}", entry.object_type).to_lowercase())
425 });
426
427 Ok(RepositoryView {
428 name: repository.name,
429 description: repository.description,
430 visibility: repository.visibility,
431 default_branch: repository.default_branch,
432 latest_commit: None,
433 tree_rev: Some(rev_name),
434 tree,
300 435 })
301 436 }
302 437

src/backend/mod.rs

View file
@@ -1,8 +1,8 @@
1 1 pub mod git;
2 2 pub mod github;
3 3
4 use std::error::Error;
5 4 use async_trait::async_trait;
5 use std::error::Error;
6 6
7 7 use crate::{
8 8 messages::{

src/messages/repository.rs

View file
@@ -1,10 +1,10 @@
1 1 use serde::{Deserialize, Serialize};
2 2
3 use crate::model::repository::RepositoryVisibility;
3 4 use crate::model::{
4 5 repository::{Commit, Repository, RepositoryTreeEntry, RepositoryView},
5 6 user::User,
6 7 };
7 use crate::model::repository::RepositoryVisibility;
8 8
9 9 #[derive(Clone, Serialize, Deserialize)]
10 10 pub struct RepositoryMessage {

src/model/repository.rs

View file
@@ -15,7 +15,8 @@ pub struct Repository {
15 15 }
16 16
17 17 /// Visibility of the repository to the general eye
18 #[derive(Debug, Serialize, Deserialize, Clone)]
18 #[derive(Debug, Hash, Serialize, Deserialize, Clone, sqlx::Type)]
19 #[sqlx(type_name = "visibility", rename_all = "lowercase")]
19 20 pub enum RepositoryVisibility {
20 21 Public,
21 22 Unlisted,
@@ -34,33 +35,46 @@ pub struct RepositoryView {
34 35 pub default_branch: String,
35 36 /// Last commit made to the repository
36 37 pub latest_commit: Option<Commit>,
38 /// Revision of the displayed tree
39 pub tree_rev: Option<String>,
37 40 /// Repository tree
38 41 pub tree: Vec<RepositoryTreeEntry>,
39 42 }
40 43
41 44 #[derive(Debug, Clone, Serialize, Deserialize)]
42 pub enum RepositoryTreeEntry {
43 Tree(RepositoryTreeEntryInfo),
44 Blob(RepositoryTreeEntryInfo),
45 }
46
47 impl RepositoryTreeEntry {
48 // I love you Emilia <3
45 pub enum RepositoryObjectType {
46 Tree,
47 Blob,
49 48 }
50 49
51 50 /// Stored info for our tree entries
52 51 #[derive(Debug, Clone, Serialize, Deserialize)]
53 pub struct RepositoryTreeEntryInfo {
52 pub struct RepositoryTreeEntry {
54 53 /// Name of the tree/blob
55 54 pub name: String,
55 /// Type of the tree entry
56 pub object_type: RepositoryObjectType,
56 57 /// Git supplies us with the mode at all times, and people like it displayed.
57 58 pub mode: i32,
58 59 /// File size
59 pub blob_size: Option<usize>,
60 pub size: Option<usize>,
60 61 /// Last commit made to the tree/blob
61 62 pub last_commit: Option<Commit>,
62 63 }
63 64
65 impl RepositoryTreeEntry {
66 // I love you Emilia <3
67 pub fn new(name: &str, object_type: RepositoryObjectType, mode: i32) -> Self {
68 Self {
69 name: name.to_string(),
70 object_type,
71 mode,
72 size: None,
73 last_commit: None,
74 }
75 }
76 }
77
64 78 #[derive(Debug, Clone, Serialize, Deserialize)]
65 79 pub struct RepositoryTreeEntryWithCommit {
66 80 pub tree_entry: RepositoryTreeEntry,
@@ -82,6 +96,21 @@ pub struct Commit {
82 96 pub time: chrono::NaiveDateTime,
83 97 }
84 98
99 /// Gets all info from [`git2::Commit`] for easy use
100 impl From<git2::Commit<'_>> for Commit {
101 fn from(commit: git2::Commit<'_>) -> Self {
102 Self {
103 oid: commit.id().to_string(),
104 message: commit
105 .message()
106 .map_or(None, |message| Some(message.to_string())),
107 author: commit.author().into(),
108 committer: commit.committer().into(),
109 time: chrono::NaiveDateTime::from_timestamp_opt(commit.time().seconds(), 0).unwrap(),
110 }
111 }
112 }
113
85 114 /// Git commit signature
86 115 #[derive(Debug, Clone, Serialize, Deserialize)]
87 116 pub struct CommitSignature {
@@ -89,3 +118,16 @@ pub struct CommitSignature {
89 118 pub email: Option<String>,
90 119 pub time: chrono::NaiveDateTime,
91 120 }
121
122 /// Converts the signature from git2 into something usable without explicit lifetimes.
123 impl From<git2::Signature<'_>> for CommitSignature {
124 fn from(signature: git2::Signature<'_>) -> Self {
125 Self {
126 name: signature.name().map_or(None, |name| Some(name.to_string())),
127 email: signature
128 .email()
129 .map_or(None, |email| Some(email.to_string())),
130 time: chrono::NaiveDateTime::from_timestamp_opt(signature.when().seconds(), 0).unwrap(),
131 }
132 }
133 }