Showing 8 changed files with 252 insertions and 48 deletions
Cargo.lock
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 | } |