Skip to content
Clone

feat(lib/git): add git crate with graphql, lfs, service, share, sync, smart, ssh, and context modules

ZhenYi committed
parent 2ebe969 commit 6097070 Browse files

84 files changed

+10482 −0

File tree 84
Added lib/git/src/context/cache.rs View lib/git/src/context/cache.rs
@@ -0,0 +1,27 @@
1use crate::context::facade::GitContext;
2
3impl GitContext {
4 pub async fn cache_insert<V>(
5 &self,
6 key: &str,
7 value: &V,
8 ttl: Option<std::time::Duration>,
9 ) -> Result<(), crate::share::errors::GitError>
10 where
11 V: serde::Serialize,
12 {
13 Ok(self.cache.set(key, value, ttl).await?)
14 }
15 pub async fn cache_get<V>(&self, key: &str) -> Result<Option<V>, crate::share::errors::GitError>
16 where
17 V: serde::de::DeserializeOwned,
18 {
19 Ok(self.cache.get(key).await?)
20 }
21 pub async fn cache_remove(&self, key: &str) -> Result<(), crate::share::errors::GitError> {
22 Ok(self.cache.delete(key).await?)
23 }
24 pub async fn cache_exists(&self, key: &str) -> Result<bool, crate::share::errors::GitError> {
25 Ok(self.cache.exists(key).await?)
26 }
27}
Added lib/git/src/context/facade.rs View lib/git/src/context/facade.rs
@@ -0,0 +1,47 @@
1use async_trait::async_trait;
2use cache::CacheClient;
3use export::AppDatabase;
4use gitbaby::error::BabyError;
5use gitbaby::repo::RepositoryFacade;
6use gix::Repository;
7use models::repo::repos;
8use std::path::PathBuf;
9use std::sync::Arc;
10use uuid::Uuid;
11
12#[derive(Clone)]
13pub struct GitContext {
14 pub model: repos::Model,
15 pub cache: Arc<CacheClient>,
16 pub database: Arc<AppDatabase>,
17 pub repo_root: PathBuf,
18 pub objects_root: PathBuf,
19}
20
21impl GitContext {
22 pub fn repo_dir(&self) -> PathBuf {
23 self.repo_root.join(self.model.id.to_string())
24 }
25}
26
27pub fn repo_dir_for(repo_root: &std::path::Path, repo_id: Uuid) -> PathBuf {
28 repo_root.join(repo_id.to_string())
29}
30
31#[async_trait]
32impl RepositoryFacade for GitContext {
33 async fn git_repo_dir(&self) -> Result<PathBuf, BabyError> {
34 self.repo_dir().canonicalize().map_err(|e| BabyError::Io {
35 path: Default::default(),
36 source: e,
37 })
38 }
39
40 async fn git_alternate_object_directories(&self) -> Result<Vec<PathBuf>, BabyError> {
41 Ok(vec![self.objects_root.clone()])
42 }
43
44 async fn gix_repo(&self) -> Result<Repository, BabyError> {
45 gix::open(&self.git_repo_dir().await?).map_err(|e| BabyError::InvalidGitArg(e.to_string()))
46 }
47}
Added lib/git/src/context/mod.rs View lib/git/src/context/mod.rs
@@ -0,0 +1,2 @@
1pub mod cache;
2pub mod facade;
Added lib/git/src/graphql/query/context.rs View lib/git/src/graphql/query/context.rs
@@ -0,0 +1,104 @@
1use std::sync::Arc;
2
3use gitbaby::tags::types::TagType as GbTagType;
4
5use crate::context::facade::GitContext;
6use crate::graphql::error::{map_git_baby, to_gql};
7use crate::graphql::types::{BlobObj, CommitObj, GitObject, TagObj, TagShape, TreeObj};
8use crate::service::GitService;
9use crate::share::errors::GitError;
10
11pub const DEFAULT_FIRST: i32 = 30;
12pub const MAX_FIRST: i32 = 100;
13
14pub(crate) fn clamp_first(first: Option<i32>) -> i32 {
15 first.unwrap_or(DEFAULT_FIRST).clamp(0, MAX_FIRST)
16}
17
18pub(crate) fn parse_oid(hex: &str) -> Result<gix::hash::ObjectId, async_graphql::Error> {
19 gix::hash::ObjectId::from_hex(hex.as_bytes())
20 .map_err(|e| async_graphql::Error::new(format!("invalid object id `{hex}`: {e}")))
21}
22
23pub(crate) fn parse_cursor(c: Option<String>) -> Result<usize, async_graphql::Error> {
24 let Some(c) = c else { return Ok(0) };
25 c.parse::<usize>()
26 .map_err(|e| async_graphql::Error::new(format!("invalid cursor `{c}`: {e}")))
27}
28
29pub fn map_git(e: GitError) -> async_graphql::Error {
30 to_gql(e)
31}
32
33#[derive(Clone)]
34pub struct RepoCtx {
35 pub svc: Arc<GitService>,
36 pub ctx: GitContext,
37 pub space_name: String,
38 pub repo_name: String,
39 pub baby: gitbaby::GitBaby,
40}
41
42impl RepoCtx {
43 pub async fn parents_of(
44 &self,
45 oid: &gix::hash::ObjectId,
46 ) -> Result<Vec<gix::hash::ObjectId>, async_graphql::Error> {
47 let info = self
48 .baby
49 .get_commit(&oid.to_string())
50 .await
51 .map_err(map_git_baby)?;
52 Ok(info.parents)
53 }
54}
55
56pub(crate) const EMPTY_SHA1: [u8; 20] = [0; 20];
57
58pub(crate) fn parse_oid_or_default(s: &str) -> gix::hash::ObjectId {
59 gix::hash::ObjectId::from_hex(s.as_bytes())
60 .unwrap_or_else(|_| gix::hash::ObjectId::from_bytes_or_panic(&EMPTY_SHA1))
61}
62
63pub async fn resolve_git_object(
64 repo: &RepoCtx,
65 oid: gix::hash::ObjectId,
66) -> async_graphql::Result<GitObject> {
67 let baby = &repo.baby;
68 if let Ok(info) = baby
69 .get_commit(&oid.to_string())
70 .await
71 .map_err(map_git_baby)
72 {
73 return Ok(GitObject::Commit(CommitObj::from_info(&info)));
74 }
75 if let Ok(entries) = baby
76 .get_tree_entries(&oid.to_string(), std::path::Path::new(""))
77 .await
78 .map_err(map_git_baby)
79 {
80 return Ok(GitObject::Tree(TreeObj {
81 oid: crate::graphql::scalars::ObjectIdScalar(oid),
82 entry_count: entries.len() as i32,
83 }));
84 }
85 if let Ok(blob) = baby
86 .get_blob(oid, gitbaby::blob::types::GetBlobOptions { limit: None })
87 .await
88 .map_err(map_git_baby)
89 {
90 return Ok(GitObject::Blob(BlobObj::new(
91 blob.info.oid,
92 &blob.data,
93 blob.info.size,
94 )));
95 }
96 Ok(GitObject::Tag(TagObj::from(TagShape {
97 name: format!("tag:{}", oid.to_string().get(..7).unwrap_or("")),
98 id: oid,
99 target: oid,
100 tag_type: GbTagType::Annotated.into(),
101 message: None,
102 tagger: None,
103 })))
104}
Added lib/git/src/graphql/query/mod.rs View lib/git/src/graphql/query/mod.rs
@@ -0,0 +1,7 @@
1mod context;
2mod repo;
3mod root;
4
5pub use context::{RepoCtx, map_git, resolve_git_object};
6pub use repo::Repo;
7pub use root::{GitSchema, Query};
Added lib/git/src/graphql/query/repo.rs View lib/git/src/graphql/query/repo.rs
@@ -0,0 +1,553 @@
1use std::path::PathBuf;
2
3use async_graphql::Object;
4use gitbaby::diff::types::DiffRequest;
5use gitbaby::refs::types::{ListRefsOptions, RefType};
6use gitbaby::setup::types::ObjectFormat;
7
8use crate::graphql::error::map_git_baby;
9use crate::graphql::types::{
10 BlameObj, BlobObj, ChangedPathDiffObj, CodeActivityObj, CommitObj, CommitStatusObj,
11 ComparisonObj, ComparisonStatus, ContributorStatObj, DiffObj, DiffSummaryObj, GitObject,
12 LanguageStatObj, LanguageStatsObj, LfsPointerObj, LogOptionsInput, MergeStatusObj,
13 ObjectFormatEnum, PackStatsObj, PageInput, PatchObj, RefObj, RepoStatsObj, RepositoryInfoObj,
14 SubmoduleObj, TagObj, TagShape, TagTypeEnum, TreeObj,
15};
16use crate::service::diffs::DiffOptionsDto;
17
18use super::context::{
19 EMPTY_SHA1, RepoCtx, clamp_first, map_git, parse_cursor, parse_oid, parse_oid_or_default,
20 resolve_git_object,
21};
22
23pub struct Repo {
24 pub inner: RepoCtx,
25}
26
27impl Repo {
28 fn baby(&self) -> &gitbaby::GitBaby {
29 &self.inner.baby
30 }
31}
32
33fn parse_object_format(s: &str) -> ObjectFormat {
34 match s {
35 "sha256" => ObjectFormat::Sha256,
36 _ => ObjectFormat::Sha1,
37 }
38}
39
40#[Object]
41impl Repo {
42 async fn info(&self) -> RepositoryInfoObj {
43 RepositoryInfoObj {
44 id: self.inner.ctx.model.id.to_string(),
45 name: self.inner.ctx.model.name.clone(),
46 default_branch: self.inner.ctx.model.default_branch.clone(),
47 is_private: self.inner.ctx.model.is_private,
48 is_archived: self.inner.ctx.model.is_archived,
49 enable_lfs: self.inner.ctx.model.enable_lfs,
50 object_format: ObjectFormatEnum::from(parse_object_format(
51 &self.inner.ctx.model.object_format,
52 )),
53 nums_branches: self.inner.ctx.model.nums_branches,
54 nums_tags: self.inner.ctx.model.nums_tags,
55 nums_commits: self.inner.ctx.model.nums_commits,
56 }
57 }
58
59 async fn repo_stats(&self) -> Result<RepoStatsObj, async_graphql::Error> {
60 let paths = self.inner.ctx.repo_dir();
61 let out = tokio::task::spawn_blocking({
62 let paths = paths.clone();
63 move || {
64 use std::process::Command;
65 Command::new("git")
66 .arg("-C")
67 .arg(&paths)
68 .args(["count-objects", "-v"])
69 .output()
70 }
71 })
72 .await
73 .map_err(map_git_baby)?
74 .map_err(map_git_baby)?;
75 if !out.status.success() {
76 return Err(async_graphql::Error::new(
77 String::from_utf8_lossy(&out.stderr).into_owned(),
78 ));
79 }
80 let mut loose = 0i64;
81 let mut in_pack = 0i64;
82 let mut size = 0i64;
83 for line in String::from_utf8_lossy(&out.stdout).lines() {
84 if let Some(rest) = line.strip_prefix("count: ") {
85 loose = rest.trim().parse().unwrap_or(0);
86 } else if let Some(rest) = line.strip_prefix("in-pack: ") {
87 in_pack = rest.trim().parse().unwrap_or(0);
88 } else if let Some(rest) = line.strip_prefix("size: ") {
89 size = rest.trim().parse().unwrap_or(0);
90 }
91 }
92 let total = loose + in_pack;
93 Ok(RepoStatsObj {
94 git_size: size,
95 size,
96 lfs_size: self.inner.ctx.model.lfs_size,
97 object_count: total,
98 loose_object_count: loose,
99 pack_count: in_pack,
100 })
101 }
102
103 async fn pack_stats(&self) -> Result<PackStatsObj, async_graphql::Error> {
104 let s = self
105 .inner
106 .svc
107 .pack_stats(self.inner.space_name.clone(), self.inner.repo_name.clone())
108 .await
109 .map_err(map_git)?;
110 Ok(PackStatsObj {
111 objects: s.loose_count,
112 size: s.size_bytes,
113 delta_count: 0,
114 })
115 }
116
117 async fn object(&self, oid: String) -> Result<GitObject, async_graphql::Error> {
118 let id = parse_oid(&oid)?;
119 resolve_git_object(&self.inner, id).await
120 }
121
122 async fn commit(
123 &self,
124 oid: Option<String>,
125 rev: Option<String>,
126 ) -> Result<CommitObj, async_graphql::Error> {
127 let spec = oid
128 .or(rev)
129 .ok_or_else(|| async_graphql::Error::new("either `oid` or `rev` must be provided"))?;
130 let info = self.baby().get_commit(&spec).await.map_err(map_git_baby)?;
131 Ok(CommitObj::from_info(&info))
132 }
133
134 async fn blob(&self, oid: String) -> Result<BlobObj, async_graphql::Error> {
135 let id = parse_oid(&oid)?;
136 let baby = self.baby();
137 let blob = baby
138 .get_blob(id, gitbaby::blob::types::GetBlobOptions { limit: None })
139 .await
140 .map_err(map_git_baby)?;
141 Ok(BlobObj::new(blob.info.oid, &blob.data, blob.info.size))
142 }
143
144 async fn tree(&self, oid: String) -> Result<TreeObj, async_graphql::Error> {
145 let id = parse_oid(&oid)?;
146 let entries = self
147 .baby()
148 .get_tree_entries(&oid, std::path::Path::new(""))
149 .await
150 .map_err(map_git_baby)?;
151 Ok(TreeObj {
152 oid: crate::graphql::scalars::ObjectIdScalar(id),
153 entry_count: entries.len() as i32,
154 })
155 }
156
157 async fn tag(&self, name: String) -> Result<TagObj, async_graphql::Error> {
158 let dto = self
159 .inner
160 .svc
161 .get_tag(
162 self.inner.space_name.clone(),
163 self.inner.repo_name.clone(),
164 name,
165 )
166 .await
167 .map_err(map_git)?;
168 let target_oid = parse_oid(&dto.target_sha)
169 .map_err(|_| async_graphql::Error::new("tag target is not a valid oid"))?;
170 Ok(TagObj::from(TagShape {
171 name: dto.name.clone(),
172 id: target_oid,
173 target: target_oid,
174 tag_type: TagTypeEnum::from(dto.tag_type),
175 message: dto.message.clone(),
176 tagger: None,
177 }))
178 }
179
180 async fn tags(&self, page: Option<PageInput>) -> Result<Vec<TagObj>, async_graphql::Error> {
181 let first = clamp_first(page.as_ref().and_then(|p| p.first));
182 let offset = parse_cursor(page.as_ref().and_then(|p| p.after.clone()))?;
183 let dtos = self
184 .inner
185 .svc
186 .list_tags(self.inner.space_name.clone(), self.inner.repo_name.clone())
187 .await
188 .map_err(map_git)?;
189 Ok(dtos
190 .into_iter()
191 .skip(offset)
192 .take(first as usize)
193 .map(|t| {
194 TagObj::from(TagShape {
195 name: t.name.clone(),
196 id: parse_oid_or_default(&t.target_sha),
197 target: parse_oid_or_default(&t.target_sha),
198 tag_type: TagTypeEnum::from(t.tag_type),
199 message: t.message.clone(),
200 tagger: None,
201 })
202 })
203 .collect())
204 }
205
206 async fn ref_(&self, name: String) -> Result<RefObj, async_graphql::Error> {
207 let info = self
208 .baby()
209 .list_refs(ListRefsOptions {
210 patterns: vec![name],
211 ..Default::default()
212 })
213 .await
214 .map_err(map_git_baby)?
215 .into_iter()
216 .next()
217 .ok_or_else(|| async_graphql::Error::new("ref not found"))?;
218 Ok(RefObj::from(&info))
219 }
220
221 async fn refs(
222 &self,
223 pattern: Option<String>,
224 page: Option<PageInput>,
225 ) -> Result<Vec<RefObj>, async_graphql::Error> {
226 let first = clamp_first(page.as_ref().and_then(|p| p.first));
227 let offset = parse_cursor(page.as_ref().and_then(|p| p.after.clone()))?;
228 let info = self
229 .baby()
230 .list_refs(ListRefsOptions {
231 patterns: vec![pattern.unwrap_or_default()],
232 ..Default::default()
233 })
234 .await
235 .map_err(map_git_baby)?;
236 Ok(info
237 .into_iter()
238 .skip(offset)
239 .take(first as usize)
240 .map(|r| RefObj::from(&r))
241 .collect())
242 }
243
244 async fn branches(&self, page: Option<PageInput>) -> Result<Vec<RefObj>, async_graphql::Error> {
245 let first = clamp_first(page.as_ref().and_then(|p| p.first));
246 let offset = parse_cursor(page.as_ref().and_then(|p| p.after.clone()))?;
247 let info = self
248 .baby()
249 .list_refs(ListRefsOptions::default())
250 .await
251 .map_err(map_git_baby)?;
252 Ok(info
253 .into_iter()
254 .filter(|r| matches!(r.ref_type, RefType::Branch))
255 .skip(offset)
256 .take(first as usize)
257 .map(|r| RefObj::from(&r))
258 .collect())
259 }
260
261 async fn log(
262 &self,
263 opts: Option<LogOptionsInput>,
264 ) -> Result<Vec<CommitObj>, async_graphql::Error> {
265 let opts = opts.unwrap_or_default();
266 let first = clamp_first(opts.first);
267 let skip = parse_cursor(opts.after.clone())? as u64;
268 let dto = self
269 .inner
270 .svc
271 .log_list(
272 self.inner.space_name.clone(),
273 self.inner.repo_name.clone(),
274 crate::service::logs::LogOptions {
275 revision: opts.rev.clone(),
276 limit: Some(first as u64),
277 skip: Some(skip),
278 },
279 )
280 .await
281 .map_err(map_git)?;
282 Ok(dto.commits.iter().map(CommitObj::from_info).collect())
283 }
284
285 async fn diff(&self, base: String, head: String) -> Result<DiffObj, async_graphql::Error> {
286 let _ = DiffRequest {
287 left: self.baby().rev_parse(&base).await.map_err(map_git_baby)?,
288 right: self.baby().rev_parse(&head).await.map_err(map_git_baby)?,
289 };
290 let files = self
291 .inner
292 .svc
293 .diff_files(
294 self.inner.space_name.clone(),
295 self.inner.repo_name.clone(),
296 base.clone(),
297 head.clone(),
298 DiffOptionsDto::default(),
299 )
300 .await
301 .map_err(map_git)?;
302 let file_objs: Vec<ChangedPathDiffObj> =
303 files.iter().map(ChangedPathDiffObj::from).collect();
304 Ok(DiffObj {
305 start: base,
306 end: head,
307 files: file_objs,
308 is_incomplete: false,
309 })
310 }
311
312 async fn diff_short_stat(
313 &self,
314 base: String,
315 head: String,
316 ) -> Result<DiffSummaryObj, async_graphql::Error> {
317 let dto = self
318 .inner
319 .svc
320 .diff_short_stat(
321 self.inner.space_name.clone(),
322 self.inner.repo_name.clone(),
323 base,
324 head,
325 )
326 .await
327 .map_err(map_git)?;
328 Ok(DiffSummaryObj {
329 num_files: dto.files_changed as i32,
330 total_addition: dto.additions as i32,
331 total_deletion: dto.deletions as i32,
332 })
333 }
334
335 async fn diff_files_changed(
336 &self,
337 base: String,
338 head: String,
339 ) -> Result<Vec<String>, async_graphql::Error> {
340 self.inner
341 .svc
342 .diff_files_changed_between(
343 self.inner.space_name.clone(),
344 self.inner.repo_name.clone(),
345 base,
346 head,
347 )
348 .await
349 .map_err(map_git)
350 }
351
352 async fn comparison(
353 &self,
354 base_ref: String,
355 head_ref: String,
356 ) -> Result<ComparisonObj, async_graphql::Error> {
357 let stat = self
358 .inner
359 .svc
360 .diff_short_stat(
361 self.inner.space_name.clone(),
362 self.inner.repo_name.clone(),
363 base_ref.clone(),
364 head_ref.clone(),
365 )
366 .await
367 .map_err(map_git)?;
368 let log = self
369 .inner
370 .svc
371 .log_commits_between(
372 self.inner.space_name.clone(),
373 self.inner.repo_name.clone(),
374 base_ref.clone(),
375 head_ref.clone(),
376 256,
377 )
378 .await
379 .map_err(map_git)?;
380 let status = if stat.additions == 0 && stat.deletions == 0 {
381 ComparisonStatus::Identical
382 } else if stat.additions > 0 && stat.deletions == 0 {
383 ComparisonStatus::Ahead
384 } else if stat.additions == 0 {
385 ComparisonStatus::Behind
386 } else {
387 ComparisonStatus::Diverged
388 };
389 Ok(ComparisonObj {
390 base_ref,
391 head_ref,
392 ahead: stat.additions as i32,
393 behind: stat.deletions as i32,
394 status,
395 num_files: stat.files_changed as i32,
396 commits: log.commits.iter().map(CommitObj::from_info).collect(),
397 })
398 }
399
400 async fn blame(&self, rev: String, path: String) -> Result<BlameObj, async_graphql::Error> {
401 let dtos = self
402 .inner
403 .svc
404 .blame(
405 self.inner.space_name.clone(),
406 self.inner.repo_name.clone(),
407 rev,
408 PathBuf::from(path),
409 None,
410 None,
411 )
412 .await
413 .map_err(map_git)?;
414 let hunks: Vec<gitbaby::blame::types::BlameHunk> = dtos
415 .into_iter()
416 .map(|r| gitbaby::blame::types::BlameHunk {
417 sha: gix::hash::ObjectId::from_hex(r.commit_sha.as_bytes())
418 .unwrap_or_else(|_| gix::hash::ObjectId::from_bytes_or_panic(&EMPTY_SHA1)),
419 previous_sha: None,
420 previous_path: r.path.map(std::path::PathBuf::from),
421 lines: Vec::new(),
422 final_start_line: r.starting_line.max(0) as u32,
423 final_line_count: (r.ending_line - r.starting_line + 1).max(0) as u32,
424 orig_start_line: 0,
425 })
426 .collect();
427 Ok(BlameObj::from_hunks(&hunks))
428 }
429
430 async fn languages(&self) -> Result<LanguageStatsObj, async_graphql::Error> {
431 let dtos = self
432 .inner
433 .svc
434 .analysis_languages_read(self.inner.space_name.clone(), self.inner.repo_name.clone())
435 .await
436 .map_err(map_git)?;
437 Ok(LanguageStatsObj {
438 total: dtos.len() as i32,
439 languages: dtos.iter().map(LanguageStatObj::from).collect(),
440 })
441 }
442
443 async fn contributors(&self) -> Result<Vec<ContributorStatObj>, async_graphql::Error> {
444 let dtos = self
445 .inner
446 .svc
447 .analysis_contributors(self.inner.space_name.clone(), self.inner.repo_name.clone())
448 .await
449 .map_err(map_git)?;
450 Ok(dtos.iter().map(ContributorStatObj::from).collect())
451 }
452
453 async fn code_activity(
454 &self,
455 branch: Option<String>,
456 ) -> Result<CodeActivityObj, async_graphql::Error> {
457 let dto = self
458 .inner
459 .svc
460 .analysis_code_activity(
461 self.inner.space_name.clone(),
462 self.inner.repo_name.clone(),
463 branch,
464 )
465 .await
466 .map_err(map_git)?;
467 Ok(CodeActivityObj::from(&dto))
468 }
469
470 async fn submodules(&self, rev: String) -> Result<Vec<SubmoduleObj>, async_graphql::Error> {
471 let dtos = self
472 .inner
473 .svc
474 .submodule_list(
475 self.inner.space_name.clone(),
476 self.inner.repo_name.clone(),
477 rev,
478 )
479 .await
480 .map_err(map_git)?;
481 Ok(dtos.iter().map(SubmoduleObj::from).collect())
482 }
483
484 async fn conflict_check(
485 &self,
486 base: String,
487 head: String,
488 ) -> Result<MergeStatusObj, async_graphql::Error> {
489 let dto = self
490 .inner
491 .svc
492 .conflict_check(
493 self.inner.space_name.clone(),
494 self.inner.repo_name.clone(),
495 base,
496 head,
497 )
498 .await
499 .map_err(map_git)?;
500 Ok(MergeStatusObj::from(&dto))
501 }
502
503 async fn patch_generate(
504 &self,
505 base: String,
506 head: String,
507 ) -> Result<PatchObj, async_graphql::Error> {
508 let dto = self
509 .inner
510 .svc
511 .patch_generate(
512 self.inner.space_name.clone(),
513 self.inner.repo_name.clone(),
514 base,
515 head,
516 )
517 .await
518 .map_err(map_git)?;
519 Ok(PatchObj::from(&dto))
520 }
521
522 async fn commit_statuses(
523 &self,
524 sha: String,
525 ) -> Result<Vec<CommitStatusObj>, async_graphql::Error> {
526 let dtos = self
527 .inner
528 .svc
529 .list_commit_statuses(
530 self.inner.space_name.clone(),
531 self.inner.repo_name.clone(),
532 sha,
533 )
534 .await
535 .map_err(map_git)?;
536 Ok(dtos.iter().map(CommitStatusObj::from).collect())
537 }
538
539 async fn lfs_pointers(&self, rev: String) -> Result<Vec<LfsPointerObj>, async_graphql::Error> {
540 let dtos = self
541 .inner
542 .svc
543 .list_lfs_pointers(
544 self.inner.space_name.clone(),
545 self.inner.repo_name.clone(),
546 rev,
547 None,
548 )
549 .await
550 .map_err(map_git)?;
551 Ok(dtos.iter().map(LfsPointerObj::from).collect())
552 }
553}
Added lib/git/src/graphql/query/root.rs View lib/git/src/graphql/query/root.rs
@@ -0,0 +1,41 @@
1use async_graphql::{Context, EmptyMutation, EmptySubscription, Object, Schema};
2use std::sync::Arc;
3
4use crate::service::GitService;
5
6use super::context::{RepoCtx, map_git};
7use super::repo::Repo;
8
9pub type GitSchema = Schema<Query, EmptyMutation, EmptySubscription>;
10
11pub struct Query;
12
13#[Object]
14impl Query {
15 async fn api_version(&self) -> &'static str {
16 "1.0.0"
17 }
18
19 async fn repo(
20 &self,
21 ctx: &Context<'_>,
22 space_name: String,
23 repo_name: String,
24 ) -> Result<Repo, async_graphql::Error> {
25 let svc = ctx.data_unchecked::<Arc<GitService>>().clone();
26 let ctx_data = svc
27 .context(space_name.clone(), repo_name.clone())
28 .await
29 .map_err(map_git)?;
30 let baby = svc.baby(&ctx_data);
31 Ok(Repo {
32 inner: RepoCtx {
33 svc,
34 ctx: ctx_data,
35 space_name,
36 repo_name,
37 baby,
38 },
39 })
40 }
41}
Added lib/git/src/graphql/types/analysis.rs View lib/git/src/graphql/types/analysis.rs
@@ -0,0 +1,85 @@
1use async_graphql::SimpleObject;
2
3use crate::service::analysis::{
4 CodeActivityAuthorDto, CodeActivityDto, ContributorStatDto, LanguageStatDto,
5};
6
7#[derive(SimpleObject, Clone, Debug)]
8pub struct LanguageStatObj {
9 pub language: String,
10 pub percentage_bp: i32,
11 pub size: i64,
12 pub is_primary: bool,
13}
14
15#[derive(SimpleObject, Clone, Debug)]
16pub struct LanguageStatsObj {
17 pub languages: Vec<LanguageStatObj>,
18 pub total: i32,
19}
20
21impl From<&LanguageStatDto> for LanguageStatObj {
22 fn from(s: &LanguageStatDto) -> Self {
23 LanguageStatObj {
24 language: s.language.clone(),
25 percentage_bp: s.percentage_bp,
26 size: s.size,
27 is_primary: s.is_primary,
28 }
29 }
30}
31
32#[derive(SimpleObject, Clone, Debug)]
33pub struct ContributorStatObj {
34 pub name: String,
35 pub email: String,
36 pub commits: i64,
37 pub additions: i64,
38 pub deletions: i64,
39}
40
41impl From<&ContributorStatDto> for ContributorStatObj {
42 fn from(s: &ContributorStatDto) -> Self {
43 ContributorStatObj {
44 name: s.name.clone(),
45 email: s.email.clone(),
46 commits: s.commits,
47 additions: s.additions,
48 deletions: s.deletions,
49 }
50 }
51}
52
53#[derive(SimpleObject, Clone, Debug)]
54pub struct CodeActivityAuthorObj {
55 pub name: String,
56 pub email: String,
57 pub commits: i64,
58}
59
60#[derive(SimpleObject, Clone, Debug)]
61pub struct CodeActivityObj {
62 pub authors: Vec<CodeActivityAuthorObj>,
63 pub total_commits: i64,
64 pub total_additions: i64,
65 pub total_deletions: i64,
66}
67
68impl From<&CodeActivityDto> for CodeActivityObj {
69 fn from(s: &CodeActivityDto) -> Self {
70 CodeActivityObj {
71 authors: s
72 .authors
73 .iter()
74 .map(|a: &CodeActivityAuthorDto| CodeActivityAuthorObj {
75 name: a.name.clone(),
76 email: a.email.clone(),
77 commits: a.commits,
78 })
79 .collect(),
80 total_commits: s.total_commits,
81 total_additions: s.total_additions,
82 total_deletions: s.total_deletions,
83 }
84 }
85}
Added lib/git/src/graphql/types/blame.rs View lib/git/src/graphql/types/blame.rs
@@ -0,0 +1,68 @@
1use async_graphql::{ComplexObject, SimpleObject};
2use gitbaby::blame::types::BlameHunk;
3
4use crate::graphql::error::map_git_baby;
5use crate::graphql::query::RepoCtx;
6
7use super::commit::CommitObj;
8
9#[derive(SimpleObject, Clone, Debug)]
10#[graphql(complex)]
11pub struct BlameRangeObj {
12 pub starting_line: i32,
13 pub ending_line: i32,
14 pub path: Option<String>,
15 pub commit_sha: String,
16 pub abbreviated_oid: String,
17}
18
19impl From<&BlameHunk> for BlameRangeObj {
20 fn from(h: &BlameHunk) -> Self {
21 let hex = h.sha.to_string();
22 BlameRangeObj {
23 starting_line: h.final_start_line as i32,
24 ending_line: (h.final_start_line as i32 + h.final_line_count as i32).saturating_sub(1),
25 path: h
26 .previous_path
27 .clone()
28 .map(|p| p.to_string_lossy().into_owned()),
29 commit_sha: hex.clone(),
30 abbreviated_oid: hex.get(..7).unwrap_or("").to_string(),
31 }
32 }
33}
34
35#[derive(SimpleObject, Clone, Debug)]
36pub struct BlameObj {
37 pub ranges: Vec<BlameRangeObj>,
38 pub total_lines: i32,
39}
40
41impl BlameObj {
42 pub fn from_hunks(hunks: &[BlameHunk]) -> Self {
43 let total_lines = hunks
44 .iter()
45 .map(|h| (h.final_start_line + h.final_line_count) as i32)
46 .max()
47 .unwrap_or(0);
48 BlameObj {
49 ranges: hunks.iter().map(Into::into).collect(),
50 total_lines,
51 }
52 }
53}
54
55#[ComplexObject]
56impl BlameRangeObj {
57 async fn commit(&self, ctx: &async_graphql::Context<'_>) -> async_graphql::Result<CommitObj> {
58 let repo = ctx.data_unchecked::<RepoCtx>();
59 let oid = gix::hash::ObjectId::from_hex(self.commit_sha.as_bytes())
60 .map_err(|e| async_graphql::Error::new(format!("invalid commit oid: {e}")))?;
61 let info = repo
62 .baby
63 .get_commit(&oid.to_string())
64 .await
65 .map_err(map_git_baby)?;
66 Ok(CommitObj::from_info(&info))
67 }
68}
Added lib/git/src/graphql/types/blob.rs View lib/git/src/graphql/types/blob.rs
@@ -0,0 +1,49 @@
1use async_graphql::SimpleObject;
2use base64::Engine;
3use base64::engine::general_purpose::STANDARD as BASE64_ENGINE;
4
5use crate::graphql::scalars::ObjectIdScalar;
6
7#[derive(SimpleObject, Clone, Debug)]
8pub struct BlobObj {
9 pub oid: ObjectIdScalar,
10 pub byte_size: i32,
11 pub is_binary: bool,
12 pub text: Option<String>,
13 pub raw_base64: String,
14}
15
16impl BlobObj {
17 pub fn new(oid: gix::hash::ObjectId, data: &[u8], size: i64) -> Self {
18 let is_binary = crate::service::blobs::is_binary(data);
19 let text = if is_binary {
20 None
21 } else {
22 String::from_utf8(data.to_vec()).ok()
23 };
24 BlobObj {
25 oid: ObjectIdScalar(oid),
26 byte_size: size as i32,
27 is_binary,
28 text,
29 raw_base64: BASE64_ENGINE.encode(data),
30 }
31 }
32}
33
34#[derive(SimpleObject, Clone, Debug)]
35pub struct LfsPointerObj {
36 pub version: String,
37 pub oid: Option<String>,
38 pub size: Option<i64>,
39}
40
41impl From<&crate::service::blobs::LfsPointerDto> for LfsPointerObj {
42 fn from(p: &crate::service::blobs::LfsPointerDto) -> Self {
43 LfsPointerObj {
44 version: p.version.clone(),
45 oid: p.oid.clone(),
46 size: p.size,
47 }
48 }
49}
Added lib/git/src/graphql/types/commit.rs View lib/git/src/graphql/types/commit.rs
@@ -0,0 +1,132 @@
1use async_graphql::{ComplexObject, SimpleObject};
2use gitbaby::commit::types::CommitInfo;
3
4use crate::graphql::error::map_git_baby;
5use crate::graphql::query::{RepoCtx, map_git};
6use crate::graphql::scalars::{DateTimeScalar, ObjectIdScalar};
7
8use super::common::{CommitMessageObj, GitActor};
9use super::enums::CommitStatusStateEnum;
10
11#[derive(SimpleObject, Clone, Debug)]
12#[graphql(complex)]
13pub struct CommitObj {
14 pub oid: ObjectIdScalar,
15 pub abbreviated_oid: String,
16 pub message: CommitMessageObj,
17 pub author: GitActor,
18 pub committer: GitActor,
19 pub signature_validated: bool,
20 pub parents_count: i32,
21}
22
23impl CommitObj {
24 pub fn from_info(c: &CommitInfo) -> Self {
25 let hex = c.id.to_string();
26 let abbreviated = hex.get(..7).unwrap_or("").to_string();
27 CommitObj {
28 oid: ObjectIdScalar(c.id),
29 abbreviated_oid: abbreviated,
30 message: CommitMessageObj::from(c.message.as_str()),
31 author: GitActor::from(&c.author),
32 committer: GitActor::from(&c.committer),
33 signature_validated: false,
34 parents_count: c.parents.len() as i32,
35 }
36 }
37}
38
39#[ComplexObject]
40impl CommitObj {
41 async fn parents(
42 &self,
43 ctx: &async_graphql::Context<'_>,
44 first: Option<i32>,
45 ) -> async_graphql::Result<Vec<CommitObj>> {
46 let repo = ctx.data_unchecked::<RepoCtx>();
47 let parent_oids = repo.parents_of(&self.oid.0).await?;
48 let limit = first
49 .filter(|n| *n >= 0)
50 .map(|n| n as usize)
51 .unwrap_or(parent_oids.len());
52 let mut out = Vec::new();
53 for parent_oid in parent_oids.iter().take(limit) {
54 let info = repo
55 .baby
56 .get_commit(&parent_oid.to_string())
57 .await
58 .map_err(map_git_baby)?;
59 out.push(CommitObj::from_info(&info));
60 }
61 Ok(out)
62 }
63
64 async fn signature(
65 &self,
66 ctx: &async_graphql::Context<'_>,
67 ) -> async_graphql::Result<VerifyResultGql> {
68 let repo = ctx.data_unchecked::<RepoCtx>();
69 let result = repo
70 .svc
71 .signature_verify_commit(
72 repo.space_name.clone(),
73 repo.repo_name.clone(),
74 self.oid.0.to_string(),
75 )
76 .await
77 .map_err(map_git)?;
78 Ok(VerifyResultGql::from(&result))
79 }
80}
81
82#[derive(SimpleObject, Clone, Debug)]
83pub struct VerifyResultGql {
84 pub signed: bool,
85 pub valid: bool,
86 pub key_id: Option<String>,
87 pub signer: Option<String>,
88 pub error_message: Option<String>,
89}
90
91impl From<&crate::service::signatures::VerifyResultDto> for VerifyResultGql {
92 fn from(v: &crate::service::signatures::VerifyResultDto) -> Self {
93 VerifyResultGql {
94 signed: v.signed,
95 valid: v.valid,
96 key_id: v.key_id.clone(),
97 signer: v.signer.clone(),
98 error_message: v.error_message.clone(),
99 }
100 }
101}
102
103#[derive(SimpleObject, Clone, Debug)]
104pub struct CommitStatusObj {
105 pub state: CommitStatusStateEnum,
106 pub context: String,
107 pub description: Option<String>,
108 pub target_url: Option<String>,
109 pub creator_id: String,
110 pub created_at: DateTimeScalar,
111 pub updated_at: Option<DateTimeScalar>,
112}
113
114impl From<&crate::service::commits::CommitStatusDto> for CommitStatusObj {
115 fn from(s: &crate::service::commits::CommitStatusDto) -> Self {
116 CommitStatusObj {
117 state: CommitStatusStateEnum::from(s.state),
118 context: s.context.clone(),
119 description: s.description.clone(),
120 target_url: s.target_url.clone(),
121 creator_id: s.creator_id.to_string(),
122 created_at: DateTimeScalar {
123 timestamp: s.created_at.unix_timestamp(),
124 offset_seconds: s.created_at.offset().whole_seconds(),
125 },
126 updated_at: s.updated_at.map(|u| DateTimeScalar {
127 timestamp: u.unix_timestamp(),
128 offset_seconds: u.offset().whole_seconds(),
129 }),
130 }
131 }
132}
Added lib/git/src/graphql/types/common.rs View lib/git/src/graphql/types/common.rs
@@ -0,0 +1,78 @@
1use async_graphql::SimpleObject;
2use gitbaby::commit::types::Signature as GBSignature;
3
4use crate::graphql::scalars::DateTimeScalar;
5
6#[derive(SimpleObject, Clone, Debug)]
7pub struct GitActor {
8 pub name: String,
9 pub email: String,
10 pub date: DateTimeScalar,
11}
12
13impl From<&GBSignature> for GitActor {
14 fn from(s: &GBSignature) -> Self {
15 GitActor {
16 name: s.name.clone(),
17 email: s.email.clone(),
18 date: DateTimeScalar::from(s),
19 }
20 }
21}
22
23#[derive(SimpleObject, Clone, Debug)]
24pub struct TrailerObj {
25 pub key: String,
26 pub value: String,
27}
28
29#[derive(SimpleObject, Clone, Debug)]
30pub struct CommitMessageObj {
31 pub raw: String,
32 pub title: String,
33 pub body: String,
34 pub trailers: Vec<TrailerObj>,
35}
36
37/// gitbaby returns the raw commit message string. Parse title (first
38/// line), body (until first blank line or trailer-like `Key: value`), and
39/// trailers (post-blank `Key: value` pairs).
40pub fn parse_commit_message(raw: &str) -> CommitMessageObj {
41 let raw = raw.trim_end_matches('\n').to_string();
42 let mut lines = raw.split('\n');
43 let title = lines.next().unwrap_or("").to_string();
44 let rest: Vec<&str> = lines.collect();
45 let split_idx = rest.iter().position(|l| {
46 l.trim().is_empty() || (l.contains(": ") && !l.starts_with(' ') && !l.starts_with('#'))
47 });
48 let (body_lines, trailer_lines) = match split_idx {
49 Some(i) => rest.split_at(i),
50 None => (rest.as_slice(), &[][..]),
51 };
52 let body = body_lines.join("\n").trim().to_string();
53 let trailers = trailer_lines
54 .iter()
55 .filter_map(|l| {
56 let (k, v) = l.split_once(':')?;
57 if k.trim().is_empty() {
58 return None;
59 }
60 Some(TrailerObj {
61 key: k.trim().to_string(),
62 value: v.trim_start().trim().to_string(),
63 })
64 })
65 .collect();
66 CommitMessageObj {
67 raw,
68 title,
69 body,
70 trailers,
71 }
72}
73
74impl From<&str> for CommitMessageObj {
75 fn from(raw: &str) -> Self {
76 parse_commit_message(raw)
77 }
78}
Added lib/git/src/graphql/types/diff.rs View lib/git/src/graphql/types/diff.rs
@@ -0,0 +1,82 @@
1use async_graphql::SimpleObject;
2use gitbaby::diff::types::{ChangedPath, ChangedPathDiff, NumStat, ShortStat};
3
4use super::enums::DiffFileTypeEnum;
5
6#[derive(SimpleObject, Clone, Debug)]
7pub struct ChangedPathDiffObj {
8 pub path: String,
9 pub old_path: Option<String>,
10 pub status: DiffFileTypeEnum,
11 pub lines_added: i64,
12 pub lines_removed: i64,
13 pub binary: bool,
14}
15
16impl From<&ChangedPathDiff> for ChangedPathDiffObj {
17 fn from(c: &ChangedPathDiff) -> Self {
18 ChangedPathDiffObj {
19 path: c.path.new_path.to_string_lossy().into_owned(),
20 old_path: c
21 .path
22 .old_path
23 .as_ref()
24 .map(|p| p.to_string_lossy().into_owned()),
25 status: DiffFileTypeEnum::from(c.path.status),
26 lines_added: c.lines_added as i64,
27 lines_removed: c.lines_removed as i64,
28 binary: c.binary,
29 }
30 }
31}
32
33impl From<&ChangedPath> for ChangedPathDiffObj {
34 fn from(c: &ChangedPath) -> Self {
35 ChangedPathDiffObj {
36 path: c.new_path.to_string_lossy().into_owned(),
37 old_path: c
38 .old_path
39 .as_ref()
40 .map(|p| p.to_string_lossy().into_owned()),
41 status: DiffFileTypeEnum::from(c.status),
42 lines_added: 0,
43 lines_removed: 0,
44 binary: false,
45 }
46 }
47}
48
49#[derive(SimpleObject, Clone, Debug)]
50pub struct DiffObj {
51 pub start: String,
52 pub end: String,
53 pub files: Vec<ChangedPathDiffObj>,
54 pub is_incomplete: bool,
55}
56
57#[derive(SimpleObject, Clone, Debug)]
58pub struct DiffSummaryObj {
59 pub num_files: i32,
60 pub total_addition: i32,
61 pub total_deletion: i32,
62}
63
64impl From<&ShortStat> for DiffSummaryObj {
65 fn from(s: &ShortStat) -> Self {
66 DiffSummaryObj {
67 num_files: s.files_changed as i32,
68 total_addition: s.additions as i32,
69 total_deletion: s.deletions as i32,
70 }
71 }
72}
73
74impl From<&NumStat> for DiffSummaryObj {
75 fn from(n: &NumStat) -> Self {
76 DiffSummaryObj {
77 num_files: 1,
78 total_addition: n.additions as i32,
79 total_deletion: n.deletions as i32,
80 }
81 }
82}
Added lib/git/src/graphql/types/enums.rs View lib/git/src/graphql/types/enums.rs
@@ -0,0 +1,169 @@
1use async_graphql::Enum;
2use gitbaby::commit::types::TreeEntryMode;
3use gitbaby::diff::types::DiffStatus;
4use gitbaby::refs::types::RefType as GbRefType;
5use gitbaby::setup::types::ObjectFormat;
6use gitbaby::tags::types::TagType as GbTagType;
7
8mod gitbaby_object {
9 pub use gix::objs::Kind;
10}
11
12#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
13pub enum GitObjectType {
14 Blob,
15 Tree,
16 Commit,
17 Tag,
18}
19
20impl From<gitbaby_object::Kind> for GitObjectType {
21 fn from(k: gitbaby_object::Kind) -> Self {
22 match k {
23 gitbaby_object::Kind::Blob => Self::Blob,
24 gitbaby_object::Kind::Tree => Self::Tree,
25 gitbaby_object::Kind::Commit => Self::Commit,
26 gitbaby_object::Kind::Tag => Self::Tag,
27 }
28 }
29}
30
31#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
32pub enum ObjectFormatEnum {
33 Sha1,
34 Sha256,
35}
36
37impl From<ObjectFormat> for ObjectFormatEnum {
38 fn from(f: ObjectFormat) -> Self {
39 match f {
40 ObjectFormat::Sha1 => Self::Sha1,
41 ObjectFormat::Sha256 => Self::Sha256,
42 }
43 }
44}
45
46#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
47pub enum RefTypeEnum {
48 Branch,
49 Tag,
50 Remote,
51 Other,
52}
53
54impl From<GbRefType> for RefTypeEnum {
55 fn from(t: GbRefType) -> Self {
56 match t {
57 GbRefType::Branch => Self::Branch,
58 GbRefType::Tag => Self::Tag,
59 GbRefType::Remote => Self::Remote,
60 GbRefType::Other => Self::Other,
61 }
62 }
63}
64
65#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
66pub enum TagTypeEnum {
67 Lightweight,
68 Annotated,
69}
70
71impl From<GbTagType> for TagTypeEnum {
72 fn from(t: GbTagType) -> Self {
73 match t {
74 GbTagType::Lightweight => Self::Lightweight,
75 GbTagType::Annotated => Self::Annotated,
76 }
77 }
78}
79
80impl From<models::git::git_tags::TagType> for TagTypeEnum {
81 fn from(t: models::git::git_tags::TagType) -> Self {
82 use models::git::git_tags::TagType as T;
83 match t {
84 T::Lightweight => Self::Lightweight,
85 T::Annotated => Self::Annotated,
86 }
87 }
88}
89
90#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
91pub enum ComparisonStatus {
92 Identical,
93 Ahead,
94 Behind,
95 Diverged,
96}
97
98#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
99pub enum TreeEntryType {
100 Blob,
101 Exec,
102 Tree,
103 Symlink,
104 Submodule,
105}
106
107impl From<TreeEntryMode> for TreeEntryType {
108 fn from(m: TreeEntryMode) -> Self {
109 match m {
110 TreeEntryMode::Blob => Self::Blob,
111 TreeEntryMode::Exec => Self::Exec,
112 TreeEntryMode::Tree => Self::Tree,
113 TreeEntryMode::Symlink => Self::Symlink,
114 TreeEntryMode::Submodule => Self::Submodule,
115 TreeEntryMode::Commit => Self::Submodule,
116 }
117 }
118}
119
120#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
121pub enum DiffFileTypeEnum {
122 Add,
123 Change,
124 Del,
125 Rename,
126 Copy,
127 TypeChanged,
128}
129
130impl From<DiffStatus> for DiffFileTypeEnum {
131 fn from(s: DiffStatus) -> Self {
132 match s {
133 DiffStatus::Added => Self::Add,
134 DiffStatus::Deleted => Self::Del,
135 DiffStatus::Modified => Self::Change,
136 DiffStatus::TypeChanged => Self::TypeChanged,
137 DiffStatus::Renamed => Self::Rename,
138 DiffStatus::Copied => Self::Copy,
139 }
140 }
141}
142
143#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
144pub enum GitSignatureState {
145 Unsigned,
146 Valid,
147 Invalid,
148 Unknown,
149}
150
151#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
152pub enum CommitStatusStateEnum {
153 Pending,
154 Success,
155 Failure,
156 Error,
157}
158
159impl From<crate::service::commits::CommitStatusDtoState> for CommitStatusStateEnum {
160 fn from(s: crate::service::commits::CommitStatusDtoState) -> Self {
161 use crate::service::commits::CommitStatusDtoState as S;
162 match s {
163 S::Pending => Self::Pending,
164 S::Success => Self::Success,
165 S::Failure => Self::Failure,
166 S::Error => Self::Error,
167 }
168 }
169}
Added lib/git/src/graphql/types/inputs.rs View lib/git/src/graphql/types/inputs.rs
@@ -0,0 +1,15 @@
1use async_graphql::InputObject;
2
3#[derive(InputObject, Clone, Debug, Default)]
4pub struct PageInput {
5 pub first: Option<i32>,
6 pub after: Option<String>,
7}
8
9#[derive(InputObject, Clone, Debug, Default)]
10pub struct LogOptionsInput {
11 pub first: Option<i32>,
12 pub after: Option<String>,
13 pub first_parent: Option<bool>,
14 pub rev: Option<String>,
15}
Added lib/git/src/graphql/types/merge.rs View lib/git/src/graphql/types/merge.rs
@@ -0,0 +1,20 @@
1use async_graphql::SimpleObject;
2
3use crate::service::conflicts::MergeStatusDto;
4
5#[derive(SimpleObject, Clone, Debug)]
6pub struct MergeStatusObj {
7 pub mergeable: bool,
8 pub fast_forwardable: bool,
9 pub conflicts: Vec<String>,
10}
11
12impl From<&MergeStatusDto> for MergeStatusObj {
13 fn from(m: &MergeStatusDto) -> Self {
14 MergeStatusObj {
15 mergeable: m.mergeable,
16 fast_forwardable: m.fast_forwardable,
17 conflicts: m.conflicts.clone(),
18 }
19 }
20}
Added lib/git/src/graphql/types/mod.rs View lib/git/src/graphql/types/mod.rs
@@ -0,0 +1,38 @@
1mod analysis;
2mod blame;
3mod blob;
4mod commit;
5mod common;
6mod diff;
7mod enums;
8mod inputs;
9mod merge;
10mod objects;
11mod patch;
12mod refs;
13mod repo;
14mod submodule;
15mod tag;
16mod tree;
17
18pub use analysis::{
19 CodeActivityAuthorObj, CodeActivityObj, ContributorStatObj, LanguageStatObj, LanguageStatsObj,
20};
21pub use blame::{BlameObj, BlameRangeObj};
22pub use blob::{BlobObj, LfsPointerObj};
23pub use commit::{CommitObj, CommitStatusObj, VerifyResultGql};
24pub use common::{CommitMessageObj, GitActor, TrailerObj};
25pub use diff::{ChangedPathDiffObj, DiffObj, DiffSummaryObj};
26pub use enums::{
27 CommitStatusStateEnum, ComparisonStatus, DiffFileTypeEnum, GitObjectType, GitSignatureState,
28 ObjectFormatEnum, RefTypeEnum, TagTypeEnum, TreeEntryType,
29};
30pub use inputs::{LogOptionsInput, PageInput};
31pub use merge::MergeStatusObj;
32pub use objects::GitObject;
33pub use patch::PatchObj;
34pub use refs::RefObj;
35pub use repo::{ComparisonObj, PackStatsObj, RepoStatsObj, RepositoryInfoObj};
36pub use submodule::SubmoduleObj;
37pub use tag::{TagObj, TagShape};
38pub use tree::{TreeEntryObj, TreeEntryWithLatestObj, TreeObj};
Added lib/git/src/graphql/types/objects.rs View lib/git/src/graphql/types/objects.rs
@@ -0,0 +1,15 @@
1use async_graphql::Union;
2
3use super::blob::BlobObj;
4use super::commit::CommitObj;
5use super::tag::TagObj;
6use super::tree::TreeObj;
7
8#[derive(Union, Clone, Debug)]
9#[graphql(name = "GitObject")]
10pub enum GitObject {
11 Commit(CommitObj),
12 Blob(BlobObj),
13 Tree(TreeObj),
14 Tag(TagObj),
15}
Added lib/git/src/graphql/types/patch.rs View lib/git/src/graphql/types/patch.rs
@@ -0,0 +1,18 @@
1use async_graphql::SimpleObject;
2
3use crate::service::patches::PatchDto;
4
5#[derive(SimpleObject, Clone, Debug)]
6pub struct PatchObj {
7 pub content: String,
8 pub size: i64,
9}
10
11impl From<&PatchDto> for PatchObj {
12 fn from(p: &PatchDto) -> Self {
13 PatchObj {
14 content: p.content.clone(),
15 size: p.size,
16 }
17 }
18}
Added lib/git/src/graphql/types/refs.rs View lib/git/src/graphql/types/refs.rs
@@ -0,0 +1,55 @@
1use async_graphql::{ComplexObject, SimpleObject};
2use gitbaby::refs::types::RefInfo;
3
4use crate::graphql::query::{RepoCtx, resolve_git_object};
5use crate::graphql::scalars::ObjectIdScalar;
6
7use super::enums::RefTypeEnum;
8use super::objects::GitObject;
9
10#[derive(SimpleObject, Clone, Debug)]
11#[graphql(complex)]
12pub struct RefObj {
13 pub name: String,
14 pub prefix: String,
15 pub short_name: String,
16 pub target_oid: ObjectIdScalar,
17 pub type_: RefTypeEnum,
18 pub is_protected: bool,
19}
20
21impl From<&RefInfo> for RefObj {
22 fn from(info: &RefInfo) -> Self {
23 let (prefix, short) = split_ref_name(&info.name);
24 RefObj {
25 name: info.name.clone(),
26 prefix,
27 short_name: short,
28 target_oid: ObjectIdScalar(info.sha),
29 type_: RefTypeEnum::from(info.ref_type),
30 is_protected: false,
31 }
32 }
33}
34
35#[ComplexObject]
36impl RefObj {
37 async fn target(&self, ctx: &async_graphql::Context<'_>) -> async_graphql::Result<GitObject> {
38 let repo = ctx.data_unchecked::<RepoCtx>();
39 resolve_git_object(repo, self.target_oid.0).await
40 }
41}
42
43pub(crate) fn split_ref_name(name: &str) -> (String, String) {
44 if let Some(rest) = name.strip_prefix("refs/heads/") {
45 ("refs/heads".to_string(), rest.to_string())
46 } else if let Some(rest) = name.strip_prefix("refs/tags/") {
47 ("refs/tags".to_string(), rest.to_string())
48 } else if let Some(rest) = name.strip_prefix("refs/remotes/") {
49 ("refs/remotes".to_string(), rest.to_string())
50 } else if let Some(rest) = name.strip_prefix("refs/") {
51 (format!("refs/{rest}"), String::new())
52 } else {
53 (String::new(), name.to_string())
54 }
55}
Added lib/git/src/graphql/types/repo.rs View lib/git/src/graphql/types/repo.rs
@@ -0,0 +1,46 @@
1use async_graphql::SimpleObject;
2
3use super::commit::CommitObj;
4use super::enums::{ComparisonStatus, ObjectFormatEnum};
5
6#[derive(SimpleObject, Clone, Debug)]
7pub struct RepositoryInfoObj {
8 pub id: String,
9 pub name: String,
10 pub default_branch: String,
11 pub is_private: bool,
12 pub is_archived: bool,
13 pub enable_lfs: bool,
14 pub object_format: ObjectFormatEnum,
15 pub nums_branches: i32,
16 pub nums_tags: i32,
17 pub nums_commits: i32,
18}
19
20#[derive(SimpleObject, Clone, Debug)]
21pub struct RepoStatsObj {
22 pub size: i64,
23 pub git_size: i64,
24 pub lfs_size: i64,
25 pub object_count: i64,
26 pub loose_object_count: i64,
27 pub pack_count: i64,
28}
29
30#[derive(SimpleObject, Clone, Debug)]
31pub struct PackStatsObj {
32 pub objects: i64,
33 pub size: i64,
34 pub delta_count: i64,
35}
36
37#[derive(SimpleObject, Clone, Debug)]
38pub struct ComparisonObj {
39 pub base_ref: String,
40 pub head_ref: String,
41 pub ahead: i32,
42 pub behind: i32,
43 pub status: ComparisonStatus,
44 pub num_files: i32,
45 pub commits: Vec<CommitObj>,
46}
Added lib/git/src/graphql/types/submodule.rs View lib/git/src/graphql/types/submodule.rs
@@ -0,0 +1,22 @@
1use async_graphql::SimpleObject;
2
3use crate::service::submodules::SubmoduleDto;
4
5#[derive(SimpleObject, Clone, Debug)]
6pub struct SubmoduleObj {
7 pub path: String,
8 pub url: String,
9 pub branch: Option<String>,
10 pub commit_sha: Option<String>,
11}
12
13impl From<&SubmoduleDto> for SubmoduleObj {
14 fn from(s: &SubmoduleDto) -> Self {
15 SubmoduleObj {
16 path: s.path.clone(),
17 url: s.url.clone(),
18 branch: s.branch.clone(),
19 commit_sha: s.commit_sha.clone(),
20 }
21 }
22}
Added lib/git/src/graphql/types/tag.rs View lib/git/src/graphql/types/tag.rs
@@ -0,0 +1,53 @@
1use async_graphql::{ComplexObject, SimpleObject};
2use gitbaby::commit::types::Signature as GBSignature;
3
4use crate::graphql::query::{RepoCtx, resolve_git_object};
5use crate::graphql::scalars::ObjectIdScalar;
6
7use super::common::GitActor;
8use super::enums::TagTypeEnum;
9use super::objects::GitObject;
10
11#[derive(SimpleObject, Clone, Debug)]
12#[graphql(complex)]
13pub struct TagObj {
14 pub name: String,
15 pub oid: ObjectIdScalar,
16 pub target_oid: ObjectIdScalar,
17 pub type_: TagTypeEnum,
18 pub message: Option<String>,
19 pub tagger: Option<GitActor>,
20 pub abbreviated_oid: String,
21}
22
23pub struct TagShape {
24 pub name: String,
25 pub id: gix::hash::ObjectId,
26 pub target: gix::hash::ObjectId,
27 pub tag_type: TagTypeEnum,
28 pub message: Option<String>,
29 pub tagger: Option<GBSignature>,
30}
31
32impl From<TagShape> for TagObj {
33 fn from(t: TagShape) -> Self {
34 let hex = t.id.to_string();
35 TagObj {
36 name: t.name,
37 oid: ObjectIdScalar(t.id),
38 target_oid: ObjectIdScalar(t.target),
39 type_: t.tag_type,
40 message: t.message,
41 tagger: t.tagger.as_ref().map(GitActor::from),
42 abbreviated_oid: hex.get(..7).unwrap_or("").to_string(),
43 }
44 }
45}
46
47#[ComplexObject]
48impl TagObj {
49 async fn target(&self, ctx: &async_graphql::Context<'_>) -> async_graphql::Result<GitObject> {
50 let repo = ctx.data_unchecked::<RepoCtx>();
51 resolve_git_object(repo, self.target_oid.0).await
52 }
53}
Added lib/git/src/graphql/types/tree.rs View lib/git/src/graphql/types/tree.rs
@@ -0,0 +1,120 @@
1use async_graphql::{ComplexObject, SimpleObject};
2use gitbaby::commit::types::{TreeEntry, TreeEntryMode};
3use gitbaby::tree::types::TreeEntryWithLatest;
4
5use crate::graphql::error::map_git_baby;
6use crate::graphql::query::RepoCtx;
7use crate::graphql::scalars::{DateTimeScalar, ObjectIdScalar};
8
9use super::enums::TreeEntryType;
10
11#[derive(SimpleObject, Clone, Debug)]
12#[graphql(complex)]
13pub struct TreeObj {
14 pub oid: ObjectIdScalar,
15 pub entry_count: i32,
16}
17
18#[derive(SimpleObject, Clone, Debug)]
19pub struct TreeEntryObj {
20 pub name: String,
21 pub path: String,
22 pub oid: ObjectIdScalar,
23 pub mode: i32,
24 pub type_: TreeEntryType,
25 pub size: Option<i32>,
26}
27
28impl From<&TreeEntry> for TreeEntryObj {
29 fn from(e: &TreeEntry) -> Self {
30 TreeEntryObj {
31 name: e.name.clone(),
32 path: e.name.clone(),
33 oid: ObjectIdScalar(e.oid),
34 mode: mode_octal(&e.mode),
35 type_: TreeEntryType::from(e.mode),
36 size: e.size.map(|v| v as i32),
37 }
38 }
39}
40
41fn mode_octal(m: &TreeEntryMode) -> i32 {
42 match m {
43 TreeEntryMode::Blob => 0o100644,
44 TreeEntryMode::Exec => 0o100755,
45 TreeEntryMode::Tree => 0o040000,
46 TreeEntryMode::Symlink => 0o120000,
47 TreeEntryMode::Submodule => 0o160000,
48 TreeEntryMode::Commit => 0o160000,
49 }
50}
51
52#[derive(SimpleObject, Clone, Debug)]
53pub struct TreeEntryWithLatestObj {
54 pub entry: TreeEntryObj,
55 pub last_commit: String,
56 pub last_updated: DateTimeScalar,
57}
58
59impl From<&TreeEntryWithLatest> for TreeEntryWithLatestObj {
60 fn from(t: &TreeEntryWithLatest) -> Self {
61 TreeEntryWithLatestObj {
62 entry: TreeEntryObj::from(&t.entry),
63 last_commit: t.latest.id.to_string(),
64 last_updated: DateTimeScalar::from(&t.latest.author),
65 }
66 }
67}
68
69#[ComplexObject]
70impl TreeObj {
71 async fn entries(
72 &self,
73 ctx: &async_graphql::Context<'_>,
74 path: Option<String>,
75 ) -> async_graphql::Result<Vec<TreeEntryObj>> {
76 let repo = ctx.data_unchecked::<RepoCtx>();
77 let path = path.as_deref().unwrap_or("");
78 let entries = repo
79 .baby
80 .get_tree_entries(&self.oid.0.to_string(), std::path::Path::new(path))
81 .await
82 .map_err(map_git_baby)?;
83 Ok(entries.iter().map(TreeEntryObj::from).collect())
84 }
85
86 async fn walk(
87 &self,
88 ctx: &async_graphql::Context<'_>,
89 recurse: Option<bool>,
90 show_trees: Option<bool>,
91 ) -> async_graphql::Result<Vec<TreeEntryObj>> {
92 let repo = ctx.data_unchecked::<RepoCtx>();
93 let entries = repo
94 .baby
95 .walk_tree(
96 &self.oid.0.to_string(),
97 std::path::Path::new(""),
98 gitbaby::tree::types::WalkTreeOptions {
99 recurse: recurse.unwrap_or(true),
100 show_trees: show_trees.unwrap_or(false),
101 },
102 )
103 .await
104 .map_err(map_git_baby)?;
105 Ok(entries.iter().map(TreeEntryObj::from).collect())
106 }
107
108 async fn last_updated(
109 &self,
110 ctx: &async_graphql::Context<'_>,
111 ) -> async_graphql::Result<Vec<TreeEntryWithLatestObj>> {
112 let repo = ctx.data_unchecked::<RepoCtx>();
113 let rows = repo
114 .baby
115 .tree_entries_with_latest(&self.oid.0.to_string(), None)
116 .await
117 .map_err(map_git_baby)?;
118 Ok(rows.iter().map(TreeEntryWithLatestObj::from).collect())
119 }
120}
Added lib/git/src/graphql/error.rs View lib/git/src/graphql/error.rs
@@ -0,0 +1,45 @@
1use async_graphql::ErrorExtensions;
2
3use crate::share::errors::GitError;
4
5/// Standardized error code extension on every GraphQL error.
6fn code_for(err: &GitError) -> &'static str {
7 match err {
8 GitError::NotFound => "NOT_FOUND",
9 GitError::Forbidden => "FORBIDDEN",
10 GitError::Conflict(_) => "CONFLICT",
11 GitError::InvalidArgument(_) => "INVALID_ARGUMENT",
12 GitError::Cache(_) => "CACHE_ERROR",
13 GitError::Git(_) => "GIT_ERROR",
14 GitError::Internal(_) => "INTERNAL_ERROR",
15 }
16}
17
18impl ErrorExtensions for GitError {
19 fn extend(&self) -> async_graphql::Error {
20 let code = code_for(self);
21 async_graphql::Error::new(self.to_string()).extend_with(|_, e| {
22 e.set("code", code);
23 })
24 }
25}
26
27/// Convert GitError into GraphQL error with `code` extension. Use this
28/// directly inside resolvers to keep call sites terse.
29pub fn to_gql(err: GitError) -> async_graphql::Error {
30 err.extend()
31}
32
33/// gitbaby (low-level) errors carry no GitError context; surface them as
34/// GIT_ERROR-tagged GraphQL errors.
35pub fn map_git_baby<E: std::fmt::Display>(err: E) -> async_graphql::Error {
36 async_graphql::Error::new(err.to_string()).extend_with(|_, e| {
37 e.set("code", "GIT_ERROR");
38 })
39}
40
41/// Stub for code paths that today must satisfy an internal type-witness
42/// (`map_bitbucket_error`) but aren't actually wired to bitbucket.
43pub fn map_bitbucket_error(err: GitError) -> GitError {
44 err
45}
Added lib/git/src/graphql/mod.rs View lib/git/src/graphql/mod.rs
@@ -0,0 +1,62 @@
1use std::sync::Arc;
2
3use async_graphql::{EmptyMutation, EmptySubscription, Schema};
4
5use crate::service::GitService;
6
7pub mod error;
8pub mod query;
9pub mod scalars;
10pub mod types;
11
12pub use error::to_gql;
13pub use query::{GitSchema, Query, Repo};
14pub use scalars::{Base64Scalar, DateTimeScalar, ObjectIdScalar};
15
16/// Build a read-only graphql schema. The mutation and subscription
17/// types are intentionally empty — gitdata exposes only query.
18pub fn build_schema(state: export::AppState) -> GitSchema {
19 Schema::build(Query, EmptyMutation, EmptySubscription)
20 .data(Arc::new(GitService::new(state)))
21 .finish()
22}
23
24#[cfg(test)]
25mod tests {
26 use super::*;
27
28 fn schema_with_default_state() -> GitSchema {
29 // The schema's SDL shape is independent of the embedded
30 // `GitService` — data is only consulted at request time. Build a
31 // schema without data; no requests are executed in these tests.
32 Schema::build(Query, EmptyMutation, EmptySubscription).finish()
33 }
34
35 #[test]
36 fn schema_contains_root_and_key_types() {
37 let sdl = schema_with_default_state().sdl();
38 // Print SDL for diagnostic if any assertion fails.
39 eprintln!("--- SDL ---\n{sdl}\n---");
40 assert!(sdl.contains("type Query"), "Query root missing");
41 assert!(sdl.contains("repo("), "repo entry-point missing");
42 assert!(sdl.contains("type Commit"), "Commit object missing");
43 assert!(sdl.contains("type Blob"), "Blob object missing");
44 assert!(sdl.contains("type Tree"), "Tree object missing");
45 assert!(sdl.contains("type Tag"), "Tag object missing");
46 assert!(sdl.contains("type Ref"), "Ref object missing");
47 assert!(sdl.contains("type Blame"), "Blame object missing");
48 assert!(sdl.contains("type Submodule"), "Submodule object missing");
49 assert!(sdl.contains("type LanguageStat"), "LanguageStats missing");
50 assert!(sdl.contains("type CommitStatus"), "CommitStatus missing");
51 assert!(sdl.contains("scalar ObjectId"), "ObjectId scalar missing");
52 assert!(sdl.contains("scalar DateTime"), "DateTime scalar missing");
53 assert!(sdl.contains("union GitObject"), "GitObject union missing");
54 assert!(sdl.contains("input PageInput"), "PageInput missing");
55 assert!(
56 sdl.contains("input LogOptionsInput"),
57 "LogOptionsInput missing"
58 );
59 // read-only: schema should not declare user-facing Mutation fields
60 assert!(!sdl.contains("type Mutation"), "Mutation should be empty");
61 }
62}
Added lib/git/src/graphql/scalars.rs View lib/git/src/graphql/scalars.rs
@@ -0,0 +1,136 @@
1use async_graphql::{InputValueError, InputValueResult, Scalar, ScalarType, Value};
2use base64::Engine;
3use base64::engine::general_purpose::STANDARD as BASE64_ENGINE;
4
5/// Hex-encoded git object id (sha1/sha256).
6#[derive(Clone, Copy, Debug)]
7pub struct ObjectIdScalar(pub gix::hash::ObjectId);
8
9#[Scalar(
10 name = "ObjectId",
11 specified_by_url = "https://git-scm.com/docs/gitglossary#def_object-id"
12)]
13impl ScalarType for ObjectIdScalar {
14 fn parse(value: Value) -> InputValueResult<Self> {
15 if let Value::String(s) = value {
16 let oid = gix::hash::ObjectId::from_hex(s.as_bytes())
17 .map_err(|e| InputValueError::custom(format!("invalid object id: {e}")))?;
18 return Ok(ObjectIdScalar(oid));
19 }
20 Err(InputValueError::custom("ObjectId must be a hex string"))
21 }
22
23 fn to_value(&self) -> Value {
24 Value::String(self.0.to_string())
25 }
26}
27
28impl From<gix::hash::ObjectId> for ObjectIdScalar {
29 fn from(id: gix::hash::ObjectId) -> Self {
30 ObjectIdScalar(id)
31 }
32}
33
34impl From<ObjectIdScalar> for gix::hash::ObjectId {
35 fn from(s: ObjectIdScalar) -> Self {
36 s.0
37 }
38}
39
40/// Date + offset in seconds (sign matters).
41#[derive(Clone, Copy, Debug)]
42pub struct DateTimeScalar {
43 pub timestamp: i64,
44 pub offset_seconds: i32,
45}
46
47#[Scalar(
48 name = "DateTime",
49 specified_by_url = "https://git-scm.com/docs/git-commit#_date_formats"
50)]
51impl ScalarType for DateTimeScalar {
52 fn parse(value: Value) -> InputValueResult<Self> {
53 if let Value::Object(mut obj) = value {
54 let ts = obj
55 .swap_remove("timestamp")
56 .ok_or_else(|| InputValueError::custom("DateTime: `timestamp` is required"))?;
57 let off = obj
58 .swap_remove("offsetSeconds")
59 .ok_or_else(|| InputValueError::custom("DateTime: `offsetSeconds` is required"))?;
60 let ts = match ts {
61 Value::Number(n) => n
62 .as_i64()
63 .ok_or_else(|| InputValueError::custom("DateTime: `timestamp` must be int"))?,
64 _ => {
65 return Err(InputValueError::custom(
66 "DateTime: `timestamp` must be a number",
67 ));
68 }
69 };
70 let off = match off {
71 Value::Number(n) => n.as_i64().ok_or_else(|| {
72 InputValueError::custom("DateTime: `offsetSeconds` must be int")
73 })? as i32,
74 _ => {
75 return Err(InputValueError::custom(
76 "DateTime: `offsetSeconds` must be a number",
77 ));
78 }
79 };
80 return Ok(DateTimeScalar {
81 timestamp: ts,
82 offset_seconds: off,
83 });
84 }
85 Err(InputValueError::custom(
86 "DateTime must be an object with `timestamp` and `offsetSeconds`",
87 ))
88 }
89
90 fn to_value(&self) -> Value {
91 let mut obj = async_graphql::indexmap::IndexMap::new();
92 obj.insert(
93 async_graphql::Name::new("timestamp"),
94 Value::Number(self.timestamp.into()),
95 );
96 obj.insert(
97 async_graphql::Name::new("offsetSeconds"),
98 Value::Number((self.offset_seconds as i64).into()),
99 );
100 Value::Object(obj)
101 }
102}
103
104impl From<&gitbaby::commit::types::Signature> for DateTimeScalar {
105 fn from(sig: &gitbaby::commit::types::Signature) -> Self {
106 let ts = sig.time.unix_timestamp();
107 DateTimeScalar {
108 timestamp: ts,
109 offset_seconds: sig.time.offset().whole_seconds(),
110 }
111 }
112}
113
114/// Bytes ↔ base64 string for blob payloads.
115#[derive(Clone, Debug)]
116pub struct Base64Scalar(pub Vec<u8>);
117
118#[Scalar(
119 name = "Base64",
120 specified_by_url = "https://www.rfc-editor.org/rfc/rfc4648"
121)]
122impl ScalarType for Base64Scalar {
123 fn parse(value: Value) -> InputValueResult<Self> {
124 if let Value::String(s) = value {
125 let bytes = BASE64_ENGINE
126 .decode(s.as_bytes())
127 .map_err(|e| InputValueError::custom(format!("invalid base64: {e}")))?;
128 return Ok(Base64Scalar(bytes));
129 }
130 Err(InputValueError::custom("Base64 must be a string"))
131 }
132
133 fn to_value(&self) -> Value {
134 Value::String(BASE64_ENGINE.encode(&self.0))
135 }
136}
Added lib/git/src/lfs/error.rs View lib/git/src/lfs/error.rs
@@ -0,0 +1,37 @@
1/// LFS operations reuse the crate-level `GitError`; this module re-exports it
2/// and documents how each variant must map onto the Git LFS HTTP API status
3/// codes (consumed by the future HTTP layer under `/{space}/{repo}.git/info/lfs`).
4pub use crate::share::errors::GitError;
5
6/// HTTP status for the LFS API:
7/// - not found → 404 (unknown repo / missing object / LFS disabled)
8/// - forbidden → 403 (unlock without ownership and without `force`)
9/// - conflict → 409 (duplicate lock / already exists)
10/// - invalid arg → 400 (bad oid, size over cap, empty lock path)
11/// - everything else → 500
12pub fn http_status(err: &GitError) -> u16 {
13 match err {
14 GitError::NotFound => 404,
15 GitError::Forbidden => 403,
16 GitError::Conflict(_) => 409,
17 GitError::InvalidArgument(_) => 400,
18 GitError::Cache(_) | GitError::Git(_) | GitError::Internal(_) => 500,
19 }
20}
21
22#[cfg(test)]
23mod tests {
24 use super::*;
25
26 #[test]
27 fn maps_variants_to_http_codes() {
28 assert_eq!(http_status(&GitError::NotFound), 404);
29 assert_eq!(http_status(&GitError::Forbidden), 403);
30 assert_eq!(http_status(&GitError::Conflict("dup".to_string())), 409);
31 assert_eq!(
32 http_status(&GitError::InvalidArgument("bad".to_string())),
33 400
34 );
35 assert_eq!(http_status(&GitError::Internal("boom".to_string())), 500);
36 }
37}
Added lib/git/src/lfs/handle.rs View lib/git/src/lfs/handle.rs
@@ -0,0 +1,446 @@
1use axum::Router;
2use axum::body::Body;
3use axum::extract::{Path, Query, State};
4use axum::http::{HeaderMap, StatusCode, header};
5use axum::response::{IntoResponse, Json, Response};
6use axum::routing::{get, post, put};
7use base64::Engine as _;
8use futures::StreamExt as _;
9use http_body_util::BodyDataStream;
10use models::users::user;
11use models::users::user_access_keys::{self, AccessKeyScope};
12use sea_orm::EntityTrait;
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use tokio_util::io::StreamReader;
16use uuid::Uuid;
17
18use crate::lfs::types::{
19 LfsBatchRequest, LfsBatchResponse, LfsLockCreateRequest, LfsLockListResponse, LfsOperation,
20 LfsVerifyLocksRequest, LfsVerifyLocksResponse,
21};
22use crate::lfs::{error, object};
23use crate::service::GitService;
24use crate::share::errors::GitError;
25
26const DOCUMENTATION_URL: &str = "https://github.com/git-lfs/git-lfs/blob/main/docs/api/errors.md";
27
28pub fn router(service: GitService) -> Router<GitService> {
29 Router::new()
30 .route("/info/lfs", get(discovery))
31 .route("/info/lfs/objects/batch", post(batch))
32 .route("/info/lfs/objects/verify", post(verify_object))
33 .route(
34 "/info/lfs/objects/{oid}",
35 put(upload_object).get(download_object),
36 )
37 .route("/info/lfs/locks", get(list_locks).post(create_lock))
38 .route("/info/lfs/locks/verify", post(verify_locks))
39 .route("/info/lfs/locks/{lock_id}/unlock", post(unlock_lock))
40 .with_state(service)
41}
42
43pub struct BasicAuth {
44 pub token: Option<String>,
45}
46
47impl<S: Send + Sync> axum::extract::FromRequestParts<S> for BasicAuth {
48 type Rejection = LfsHttpError;
49
50 async fn from_request_parts(
51 parts: &mut axum::http::request::Parts,
52 _state: &S,
53 ) -> Result<Self, Self::Rejection> {
54 let Some(value) = parts.headers.get(header::AUTHORIZATION) else {
55 return Ok(BasicAuth { token: None });
56 };
57 let raw = value
58 .to_str()
59 .map_err(|_| LfsHttpError::bad_request("malformed authorization header"))?;
60 let Some(b64) = raw.strip_prefix("Basic ") else {
61 return Err(LfsHttpError::unauthorized());
62 };
63 let decoded = base64::engine::general_purpose::STANDARD
64 .decode(b64)
65 .map_err(|_| LfsHttpError::bad_request("malformed basic credentials"))?;
66 let creds = String::from_utf8(decoded)
67 .map_err(|_| LfsHttpError::bad_request("malformed basic credentials"))?;
68 let token = creds.split_once(':').map(|(_, pass)| pass.to_string());
69 Ok(BasicAuth { token })
70 }
71}
72
73pub async fn verify_token(
74 svc: &GitService,
75 token: &str,
76) -> Result<Option<(Uuid, AccessKeyScope)>, GitError> {
77 let Some((key_id, secret)) = token.split_once('.') else {
78 return Ok(None);
79 };
80 let Ok(key_id) = Uuid::parse_str(key_id) else {
81 return Ok(None);
82 };
83 let db = svc.state.database.clone();
84 let Some(row) = user_access_keys::Entity::find_by_id(key_id)
85 .one(db.read())
86 .await?
87 else {
88 return Ok(None);
89 };
90 let Some(caller) = user::Entity::find_by_id(row.user_id).one(db.read()).await? else {
91 return Ok(None);
92 };
93 if !caller.is_active || caller.prohibit_login || caller.is_restricted {
94 return Ok(None);
95 }
96 let computed = sha256_hex(format!("{secret}{}", row.token_salt).as_bytes());
97 if computed.as_bytes() != row.token_hash.as_bytes() {
98 return Ok(None);
99 }
100 Ok(Some((row.user_id, row.scope)))
101}
102
103pub async fn require_writer(svc: &GitService, auth: &BasicAuth) -> Result<Uuid, LfsHttpError> {
104 let Some(token) = &auth.token else {
105 return Err(LfsHttpError::unauthorized());
106 };
107 let (uid, scope) = verify_token(svc, token)
108 .await
109 .map_err(LfsHttpError::from)?
110 .ok_or_else(LfsHttpError::unauthorized)?;
111 if !scope.allows(true) {
112 return Err(LfsHttpError::forbidden(
113 "access key does not allow write operations",
114 ));
115 }
116 Ok(uid)
117}
118
119pub async fn allow_read(
120 svc: &GitService,
121 auth: &BasicAuth,
122 repo_private: bool,
123) -> Result<Option<Uuid>, LfsHttpError> {
124 let Some(token) = &auth.token else {
125 if repo_private {
126 return Err(LfsHttpError::unauthorized());
127 }
128 return Ok(None);
129 };
130 let (uid, _) = verify_token(svc, token)
131 .await
132 .map_err(LfsHttpError::from)?
133 .ok_or_else(LfsHttpError::unauthorized)?;
134 Ok(Some(uid))
135}
136
137pub async fn discovery(
138 State(svc): State<GitService>,
139 Path((space, repo_slug)): Path<(String, String)>,
140) -> Result<Json<Discovery>, LfsHttpError> {
141 let repo = skinny_repo_name(&repo_slug);
142 let base = svc
143 .state
144 .config
145 .web
146 .web_base_url
147 .trim_end_matches('/')
148 .to_string();
149 let href = if base.is_empty() {
150 format!("/{space}/{repo}.git/info/lfs")
151 } else {
152 format!("{base}/{space}/{repo}.git/info/lfs")
153 };
154 Ok(Json(Discovery { href }))
155}
156
157#[derive(Debug, Serialize)]
158pub struct Discovery {
159 pub href: String,
160}
161
162pub async fn batch(
163 State(svc): State<GitService>,
164 auth: BasicAuth,
165 Path((space, repo_slug)): Path<(String, String)>,
166 Json(req): Json<LfsBatchRequest>,
167) -> Result<Json<LfsBatchResponse>, LfsHttpError> {
168 let repo = skinny_repo_name(&repo_slug);
169 let needs_write = matches!(req.operation, LfsOperation::Upload | LfsOperation::Verify);
170 if needs_write {
171 require_writer(&svc, &auth).await?;
172 } else {
173 let ctx = svc
174 .context(space.clone(), repo.to_string())
175 .await
176 .map_err(LfsHttpError::from)?;
177 allow_read(&svc, &auth, ctx.model.is_private).await?;
178 }
179 let resp = object::batch(&svc, space, repo.to_string(), req)
180 .await
181 .map_err(LfsHttpError::from)?;
182 Ok(Json(resp))
183}
184
185pub async fn upload_object(
186 State(svc): State<GitService>,
187 auth: BasicAuth,
188 Path((space, repo_slug, oid)): Path<(String, String, String)>,
189 headers: HeaderMap,
190 body: Body,
191) -> Result<StatusCode, LfsHttpError> {
192 require_writer(&svc, &auth).await?;
193 let repo = skinny_repo_name(&repo_slug);
194 let size = content_length(&headers).map_err(LfsHttpError::bad_request)?;
195 let content_type = headers
196 .get(header::CONTENT_TYPE)
197 .and_then(|v| v.to_str().ok())
198 .map(str::to_string);
199 let stream = BodyDataStream::new(body).map(|chunk| match chunk {
200 Ok(bytes) => Ok(bytes),
201 Err(e) => Err(std::io::Error::other(e.to_string())),
202 });
203 let reader = StreamReader::new(stream);
204 object::upload_stream(
205 &svc,
206 space,
207 repo.to_string(),
208 Uuid::nil(),
209 oid.clone(),
210 size,
211 content_type,
212 reader,
213 )
214 .await
215 .map_err(LfsHttpError::from)?;
216 Ok(StatusCode::OK)
217}
218
219pub async fn download_object(
220 State(svc): State<GitService>,
221 auth: BasicAuth,
222 Path((space, repo_slug, oid)): Path<(String, String, String)>,
223) -> Result<Response, LfsHttpError> {
224 let repo = skinny_repo_name(&repo_slug);
225 let ctx = svc
226 .context(space.clone(), repo.to_string())
227 .await
228 .map_err(LfsHttpError::from)?;
229 allow_read(&svc, &auth, ctx.model.is_private).await?;
230 let oid = crate::lfs::canonical_oid(&oid)?;
231 let key = format!("lfs/{}/{}", ctx.model.id.simple(), oid);
232 let storage = &svc.state.storage;
233 let stream = storage
234 .client()
235 .get_stream(&key)
236 .await
237 .map_err(|e| LfsHttpError::from(GitError::Internal(format!("lfs get: {e}"))))?;
238 let body = Body::from_stream(
239 stream.map(|chunk| chunk.map_err(|e| -> axum::Error { axum::Error::new(e.to_string()) })),
240 );
241 Ok((StatusCode::OK, body).into_response())
242}
243
244pub async fn verify_object(
245 State(svc): State<GitService>,
246 auth: BasicAuth,
247 Path((space, repo_slug)): Path<(String, String)>,
248 Json(req): Json<crate::lfs::types::LfsVerifyRequest>,
249) -> Result<StatusCode, LfsHttpError> {
250 require_writer(&svc, &auth).await?;
251 let repo = skinny_repo_name(&repo_slug);
252 object::verify(&svc, space, repo.to_string(), req)
253 .await
254 .map_err(LfsHttpError::from)?;
255 Ok(StatusCode::OK)
256}
257
258pub async fn list_locks(
259 State(svc): State<GitService>,
260 auth: BasicAuth,
261 Path((space, repo_slug)): Path<(String, String)>,
262 Query(q): Query<ListLocksQuery>,
263) -> Result<Json<LfsLockListResponse>, LfsHttpError> {
264 let repo = skinny_repo_name(&repo_slug);
265 let ctx = svc
266 .context(space.clone(), repo.to_string())
267 .await
268 .map_err(LfsHttpError::from)?;
269 allow_read(&svc, &auth, ctx.model.is_private).await?;
270 let resp = crate::lfs::list_locks(&svc, space, repo.to_string(), q.cursor, q.limit)
271 .await
272 .map_err(LfsHttpError::from)?;
273 Ok(Json(resp))
274}
275
276pub async fn create_lock(
277 State(svc): State<GitService>,
278 auth: BasicAuth,
279 Path((space, repo_slug)): Path<(String, String)>,
280 Json(req): Json<LfsLockCreateRequest>,
281) -> Result<(StatusCode, Json<LockResponse>), LfsHttpError> {
282 let uid = require_writer(&svc, &auth).await?;
283 let repo = skinny_repo_name(&repo_slug);
284 let lock = crate::lfs::create_lock(&svc, space, repo.to_string(), uid, req.path, None)
285 .await
286 .map_err(LfsHttpError::from)?;
287 Ok((StatusCode::CREATED, Json(LockResponse { lock })))
288}
289
290#[derive(Debug, Serialize)]
291pub struct LockResponse {
292 lock: crate::lfs::types::LfsLock,
293}
294
295pub async fn verify_locks(
296 State(svc): State<GitService>,
297 auth: BasicAuth,
298 Path((space, repo_slug)): Path<(String, String)>,
299 Json(req): Json<LfsVerifyLocksRequest>,
300) -> Result<Json<LfsVerifyLocksResponse>, LfsHttpError> {
301 let ctx = svc
302 .context(space.clone(), skinny_repo_name(&repo_slug).to_string())
303 .await
304 .map_err(LfsHttpError::from)?;
305 let uid = match allow_read(&svc, &auth, ctx.model.is_private).await? {
306 Some(uid) => uid,
307 None => {
308 return Err(LfsHttpError::unauthorized());
309 }
310 };
311 let resp = crate::lfs::verify_locks(
312 &svc,
313 space,
314 skinny_repo_name(&repo_slug).to_string(),
315 uid,
316 req.limit.unwrap_or(100) as u64,
317 )
318 .await
319 .map_err(LfsHttpError::from)?;
320 Ok(Json(resp))
321}
322
323pub async fn unlock_lock(
324 State(svc): State<GitService>,
325 auth: BasicAuth,
326 Path((space, repo_slug, lock_id)): Path<(String, String, String)>,
327 Query(q): Query<UnlockQuery>,
328) -> Result<Json<UnlockResponse>, LfsHttpError> {
329 let uid = require_writer(&svc, &auth).await?;
330 let repo = skinny_repo_name(&repo_slug);
331 crate::lfs::unlock_by_id(&svc, space, repo.to_string(), uid, lock_id, q.force)
332 .await
333 .map_err(LfsHttpError::from)?;
334 Ok(Json(UnlockResponse {
335 message: "unlock successful".to_string(),
336 }))
337}
338
339#[derive(Debug, Serialize)]
340pub struct UnlockResponse {
341 message: String,
342}
343
344#[derive(Debug, Deserialize)]
345pub struct ListLocksQuery {
346 pub cursor: Option<String>,
347 pub limit: u64,
348}
349
350#[derive(Debug, Deserialize)]
351pub struct UnlockQuery {
352 #[serde(default)]
353 pub force: bool,
354}
355
356fn skinny_repo_name(slug: &str) -> &str {
357 slug.strip_suffix(".git").unwrap_or(slug)
358}
359
360fn content_length(headers: &HeaderMap) -> Result<i64, &'static str> {
361 let raw = headers
362 .get(header::CONTENT_LENGTH)
363 .ok_or("missing Content-Length header")?;
364 raw.to_str()
365 .ok()
366 .and_then(|s| s.parse::<i64>().ok())
367 .ok_or("invalid Content-Length header")
368}
369
370fn sha256_hex(data: &[u8]) -> String {
371 let digest = Sha256::digest(data);
372 digest.iter().map(|b| format!("{b:02x}")).collect()
373}
374
375#[derive(Debug, Serialize)]
376struct LfsErrorBody {
377 message: String,
378 request_id: String,
379 documentation_url: &'static str,
380}
381
382fn status_json(status: StatusCode, message: &str) -> Response {
383 (
384 status,
385 Json(LfsErrorBody {
386 message: message.to_string(),
387 request_id: Uuid::now_v7().simple().to_string(),
388 documentation_url: DOCUMENTATION_URL,
389 }),
390 )
391 .into_response()
392}
393
394fn challenge() -> Response {
395 (
396 StatusCode::UNAUTHORIZED,
397 [(header::WWW_AUTHENTICATE, "Basic realm=\"gitdata\"")],
398 Json(LfsErrorBody {
399 message: "authentication required".to_string(),
400 request_id: Uuid::now_v7().simple().to_string(),
401 documentation_url: DOCUMENTATION_URL,
402 }),
403 )
404 .into_response()
405}
406
407#[derive(Debug)]
408pub struct LfsHttpError {
409 response: Response,
410}
411
412impl LfsHttpError {
413 fn unauthorized() -> Self {
414 Self {
415 response: challenge(),
416 }
417 }
418
419 fn forbidden(message: &str) -> Self {
420 Self {
421 response: status_json(StatusCode::FORBIDDEN, message),
422 }
423 }
424
425 fn bad_request(message: &str) -> Self {
426 Self {
427 response: status_json(StatusCode::BAD_REQUEST, message),
428 }
429 }
430}
431
432impl IntoResponse for LfsHttpError {
433 fn into_response(self) -> Response {
434 self.response
435 }
436}
437
438impl From<GitError> for LfsHttpError {
439 fn from(err: GitError) -> Self {
440 let status = StatusCode::from_u16(error::http_status(&err))
441 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
442 Self {
443 response: status_json(status, &err.to_string()),
444 }
445 }
446}
Added lib/git/src/lfs/lock.rs View lib/git/src/lfs/lock.rs
@@ -0,0 +1,238 @@
1use models::git::git_lfs_lock;
2use models::users::user;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
5};
6use time::OffsetDateTime;
7use uuid::Uuid;
8
9use crate::lfs::types::{LfsLock, LfsLockListResponse, LfsLockOwner, LfsVerifyLocksResponse};
10use crate::lfs::validate::{canonical_oid, validate_lock_path};
11use crate::service::GitService;
12use crate::share::errors::GitError;
13
14const DEFAULT_LOCK_LIMIT: u64 = 100;
15const MAX_LOCK_LIMIT: u64 = 100;
16
17/// Create a lock for `path`. A duplicate path in the same repo surfaces as a
18/// `Conflict` (409 for the future HTTP layer).
19pub async fn create(
20 svc: &GitService,
21 space_name: String,
22 repo_name: String,
23 caller: Uuid,
24 path: String,
25 oid: Option<String>,
26) -> Result<LfsLock, GitError> {
27 let ctx = svc.context(space_name, repo_name).await?;
28 if !ctx.model.enable_lfs {
29 return Err(GitError::NotFound);
30 }
31 let path = validate_lock_path(&path)?.to_string();
32 let oid = match oid {
33 Some(raw) => Some(canonical_oid(&raw)?),
34 None => None,
35 };
36 let now = OffsetDateTime::now_utc();
37 let row = git_lfs_lock::ActiveModel {
38 id: Set(Uuid::now_v7()),
39 repo_id: Set(ctx.model.id),
40 path: Set(path.clone()),
41 owner_id: Set(caller),
42 oid: Set(oid),
43 created_at: Set(now),
44 };
45 let inserted: git_lfs_lock::Model = row.insert(svc.db_write()).await?;
46 Ok(LfsLock {
47 id: inserted.id.to_string(),
48 path: inserted.path,
49 locked_at: format_time(inserted.created_at),
50 owner: LfsLockOwner {
51 name: owner_name(svc, caller).await,
52 },
53 })
54}
55
56/// Unlock `path`. Without `force` only the lock owner may unlock; otherwise
57/// returns `Forbidden`.
58pub async fn unlock(
59 svc: &GitService,
60 space_name: String,
61 repo_name: String,
62 caller: Uuid,
63 path: String,
64 force: bool,
65) -> Result<(), GitError> {
66 let ctx = svc.context(space_name, repo_name).await?;
67 let row = git_lfs_lock::Entity::find()
68 .filter(git_lfs_lock::Column::RepoId.eq(ctx.model.id))
69 .filter(git_lfs_lock::Column::Path.eq(&path))
70 .one(svc.db_write())
71 .await?
72 .ok_or(GitError::NotFound)?;
73 if row.owner_id != caller && !force {
74 return Err(GitError::Forbidden);
75 }
76 git_lfs_lock::Entity::delete_by_id(row.id)
77 .exec(svc.db_write())
78 .await?;
79 Ok(())
80}
81
82/// Unlock by lock id (the shape used by the LFS HTTP API:
83/// `POST locks/{id}/unlock`). Same ownership/`force` rules as [`unlock`].
84pub async fn unlock_by_id(
85 svc: &GitService,
86 space_name: String,
87 repo_name: String,
88 caller: Uuid,
89 lock_id: String,
90 force: bool,
91) -> Result<(), GitError> {
92 let ctx = svc.context(space_name, repo_name).await?;
93 let id = Uuid::parse_str(&lock_id).map_err(|_| GitError::NotFound)?;
94 let row = git_lfs_lock::Entity::find()
95 .filter(git_lfs_lock::Column::RepoId.eq(ctx.model.id))
96 .filter(git_lfs_lock::Column::Id.eq(id))
97 .one(svc.db_write())
98 .await?
99 .ok_or(GitError::NotFound)?;
100 if row.owner_id != caller && !force {
101 return Err(GitError::Forbidden);
102 }
103 git_lfs_lock::Entity::delete_by_id(row.id)
104 .exec(svc.db_write())
105 .await?;
106 Ok(())
107}
108
109/// List locks, oldest first, capped by `limit` (default/max 100). When the
110/// page is full, `next_cursor` carries the last `locked_at` RFC3339 value so
111/// the client can page forward.
112pub async fn list(
113 svc: &GitService,
114 space_name: String,
115 repo_name: String,
116 cursor: Option<String>,
117 limit: u64,
118) -> Result<LfsLockListResponse, GitError> {
119 let ctx = svc.context(space_name, repo_name).await?;
120 let limit = normalize_limit(limit);
121 let mut find = git_lfs_lock::Entity::find()
122 .filter(git_lfs_lock::Column::RepoId.eq(ctx.model.id))
123 .order_by_asc(git_lfs_lock::Column::CreatedAt);
124 if let Some(cursor) = cursor {
125 let at = parse_cursor(&cursor)?;
126 find = find.filter(git_lfs_lock::Column::CreatedAt.gt(at));
127 }
128 let mut rows = find.limit(limit + 1).all(svc.db_read()).await?;
129 let has_more = rows.len() as u64 > limit;
130 rows.truncate(limit as usize);
131 let locks = to_locks(svc, rows).await;
132 let next_cursor = if has_more {
133 locks.last().map(|l| l.locked_at.clone())
134 } else {
135 None
136 };
137 Ok(LfsLockListResponse { locks, next_cursor })
138}
139
140/// Split this repo's locks into `ours` (locked by the caller) and `theirs`.
141pub async fn verify_locks(
142 svc: &GitService,
143 space_name: String,
144 repo_name: String,
145 caller: Uuid,
146 limit: u64,
147) -> Result<LfsVerifyLocksResponse, GitError> {
148 let ctx = svc.context(space_name, repo_name).await?;
149 let rows = git_lfs_lock::Entity::find()
150 .filter(git_lfs_lock::Column::RepoId.eq(ctx.model.id))
151 .order_by_asc(git_lfs_lock::Column::CreatedAt)
152 .limit(normalize_limit(limit))
153 .all(svc.db_read())
154 .await?;
155 let mut ours = Vec::new();
156 let mut theirs = Vec::new();
157 for row in rows {
158 let lock = LfsLock {
159 id: row.id.to_string(),
160 path: row.path,
161 locked_at: format_time(row.created_at),
162 owner: LfsLockOwner {
163 name: owner_name(svc, row.owner_id).await,
164 },
165 };
166 if row.owner_id == caller {
167 ours.push(lock);
168 } else {
169 theirs.push(lock);
170 }
171 }
172 Ok(LfsVerifyLocksResponse {
173 ours,
174 theirs,
175 next_cursor: None,
176 })
177}
178
179async fn to_locks(svc: &GitService, rows: Vec<git_lfs_lock::Model>) -> Vec<LfsLock> {
180 let mut locks = Vec::with_capacity(rows.len());
181 for row in rows {
182 locks.push(LfsLock {
183 id: row.id.to_string(),
184 path: row.path,
185 locked_at: format_time(row.created_at),
186 owner: LfsLockOwner {
187 name: owner_name(svc, row.owner_id).await,
188 },
189 });
190 }
191 locks
192}
193
194async fn owner_name(svc: &GitService, uid: Uuid) -> String {
195 match user::Entity::find_by_id(uid).one(svc.db_read()).await {
196 Ok(Some(u)) if !u.display_name.trim().is_empty() => u.display_name,
197 Ok(Some(u)) => u.username,
198 _ => uid.to_string(),
199 }
200}
201
202fn format_time(at: OffsetDateTime) -> String {
203 at.format(&time::format_description::well_known::Rfc3339)
204 .unwrap_or_default()
205}
206
207fn parse_cursor(cursor: &str) -> Result<OffsetDateTime, GitError> {
208 OffsetDateTime::parse(cursor, &time::format_description::well_known::Rfc3339)
209 .map_err(|e| GitError::InvalidArgument(format!("invalid cursor: {e}")))
210}
211
212fn normalize_limit(limit: u64) -> u64 {
213 match limit {
214 0 => DEFAULT_LOCK_LIMIT,
215 n if n > MAX_LOCK_LIMIT => MAX_LOCK_LIMIT,
216 n => n,
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn normalizes_limits() {
226 assert_eq!(normalize_limit(0), DEFAULT_LOCK_LIMIT);
227 assert_eq!(normalize_limit(25), 25);
228 assert_eq!(normalize_limit(500), MAX_LOCK_LIMIT);
229 }
230
231 #[test]
232 fn rfc3339_roundtrip_supports_paging() {
233 let now = OffsetDateTime::now_utc().truncate_to_second();
234 let s = format_time(now);
235 let parsed = parse_cursor(&s).expect("parse");
236 assert_eq!(parsed, now);
237 }
238}
Added lib/git/src/lfs/mod.rs View lib/git/src/lfs/mod.rs
@@ -0,0 +1,27 @@
1//! Git LFS (Large File Storage) protocol types and core logic.
2//!
3//! This module is the single implementation home for LFS in the git crate:
4//! wire types ([`types`]), input validation ([`validate`]), object
5//! upload/verify/download mechanics ([`object`]), and file locking
6//! ([`lock`]). [`error`] maps failures onto LFS HTTP semantics for the future
7//! HTTP layer mounted at `/{space}/{repo}.git/info/lfs`.
8//!
9//! [`GitService`](crate::service::GitService) methods in
10//! `service/lfs.rs` delegate here and stay the stable public entry point.
11
12pub mod error;
13pub mod handle;
14pub mod lock;
15pub mod object;
16pub mod types;
17pub mod validate;
18
19pub use error::GitError as LfsErrorKind;
20pub use handle::router as lfs_router;
21pub use lock::{create as create_lock, list as list_locks, unlock, unlock_by_id, verify_locks};
22pub use object::{
23 batch, cleanup_unreferenced, download, download_to, upload, upload_stream,
24 verify as verify_object,
25};
26pub use types::*;
27pub use validate::{canonical_oid, is_canonical_oid, validate_lock_path, validate_size};
Added lib/git/src/lfs/object.rs View lib/git/src/lfs/object.rs
@@ -0,0 +1,552 @@
1use std::collections::HashMap;
2use std::pin::Pin;
3use std::task::Poll;
4
5use export::storage::AppStorage;
6use models::git::git_lfs;
7use models::repo::repos;
8use sea_orm::sea_query::Expr;
9use sea_orm::{ColumnTrait, EntityTrait, ExprTrait, QueryFilter, Set};
10use sha2::{Digest, Sha256};
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14use crate::lfs::types::{
15 LfsAction, LfsBatchObject, LfsBatchRequest, LfsBatchResponse, LfsError, LfsOperation,
16 LfsVerifyRequest,
17};
18use crate::lfs::validate::{canonical_oid, validate_size};
19use crate::service::GitService;
20use crate::share::errors::GitError;
21
22pub const UPLOAD_TTL_SECS: u32 = 3600;
23pub const DOWNLOAD_TTL_SECS: u32 = 3600;
24
25/// `POST objects/batch` — resolve per-object actions (or per-object errors)
26/// for upload, download, or verify operations.
27pub async fn batch(
28 svc: &GitService,
29 space_name: String,
30 repo_name: String,
31 req: LfsBatchRequest,
32) -> Result<LfsBatchResponse, GitError> {
33 let ctx = svc.context(space_name.clone(), repo_name.clone()).await?;
34 if !ctx.model.enable_lfs {
35 return Err(GitError::NotFound);
36 }
37 let storage: &AppStorage = &svc.state.storage;
38 let cap = svc.state.config.git.max_blob_size;
39 let mut out: Vec<LfsBatchObject> = Vec::with_capacity(req.objects.len());
40 for obj in req.objects {
41 let oid = match canonical_oid(&obj.oid) {
42 Ok(oid) => oid,
43 Err(e) => {
44 out.push(object_error(obj.oid, obj.size, e));
45 continue;
46 }
47 };
48 if let Err(e) = validate_size(obj.size, cap) {
49 out.push(object_error(oid, obj.size, e));
50 continue;
51 }
52 let present = storage
53 .client()
54 .exists(&storage_key(&ctx.model.id, &oid))
55 .await
56 .map_err(|e| GitError::Internal(format!("storage exists: {e}")))?;
57 let actions = build_actions(
58 svc,
59 &ctx.model,
60 &space_name,
61 &repo_name,
62 &oid,
63 req.operation,
64 present,
65 )?;
66 let mut resp = LfsBatchObject {
67 oid,
68 size: obj.size,
69 actions,
70 error: None,
71 };
72 if req.operation == LfsOperation::Download && !present {
73 resp.error = Some(LfsError {
74 code: 404,
75 message: "object not found".to_string(),
76 });
77 }
78 out.push(resp);
79 }
80 Ok(LfsBatchResponse {
81 transfer: "basic".to_string(),
82 hash_algo: "sha256".to_string(),
83 objects: out,
84 })
85}
86
87fn build_actions(
88 svc: &GitService,
89 repo: &repos::Model,
90 space_name: &str,
91 repo_name: &str,
92 oid: &str,
93 operation: LfsOperation,
94 present: bool,
95) -> Result<HashMap<String, LfsAction>, GitError> {
96 let storage: &AppStorage = &svc.state.storage;
97 let mut actions: HashMap<String, LfsAction> = HashMap::new();
98 match (operation, present) {
99 (LfsOperation::Download, true) | (LfsOperation::Verify, true) => {
100 actions.insert(
101 "download".to_string(),
102 LfsAction {
103 href: storage
104 .client()
105 .presign_get(&storage_key(&repo.id, oid), DOWNLOAD_TTL_SECS)
106 .map_err(|e| GitError::Internal(format!("presign_get: {e}")))?,
107 header: None,
108 expires_at: Some(now_plus_secs(DOWNLOAD_TTL_SECS as i64)),
109 },
110 );
111 }
112 (LfsOperation::Upload, _) => {
113 let href = storage
114 .client()
115 .presign_put(&storage_key(&repo.id, oid), UPLOAD_TTL_SECS)
116 .map_err(|e| GitError::Internal(format!("presign_put: {e}")))?;
117 actions.insert(
118 "upload".to_string(),
119 LfsAction {
120 href: href.clone(),
121 header: None,
122 expires_at: Some(now_plus_secs(UPLOAD_TTL_SECS as i64)),
123 },
124 );
125 if !present {
126 actions.insert(
127 "verify".to_string(),
128 LfsAction {
129 href: verify_href(svc, space_name, repo_name),
130 header: None,
131 expires_at: Some(now_plus_secs(UPLOAD_TTL_SECS as i64)),
132 },
133 );
134 }
135 }
136 _ => {}
137 }
138 Ok(actions)
139}
140
141/// Stream an object upload: the reader is consumed chunk-by-chunk into
142/// storage while its bytes are hashed with SHA-256 on the fly. The hash is
143/// compared against the canonical oid only after the upload finishes; a
144/// mismatch (or declared-size mismatch) deletes the stored object again.
145/// Uploads are idempotent (already-present objects succeed without a second
146/// write). `repos.lfs_size` is incremented atomically.
147#[allow(clippy::too_many_arguments)]
148pub async fn upload_stream<R>(
149 svc: &GitService,
150 space_name: String,
151 repo_name: String,
152 caller: Uuid,
153 oid: String,
154 size: i64,
155 content_type: Option<String>,
156 reader: R,
157) -> Result<bool, GitError>
158where
159 R: tokio::io::AsyncRead + Unpin + Send,
160{
161 let _ = caller;
162 let ctx = svc.context(space_name, repo_name).await?;
163 if !ctx.model.enable_lfs {
164 return Err(GitError::NotFound);
165 }
166 let oid = canonical_oid(&oid)?;
167 validate_size(size, svc.state.config.git.max_blob_size)?;
168 if let Some(cap) = ctx.model.storage_limit_size
169 && ctx.model.lfs_size + size > cap
170 {
171 return Err(GitError::InvalidArgument(
172 "lfs storage limit exceeded".to_string(),
173 ));
174 }
175 let storage: &AppStorage = &svc.state.storage;
176 let key = storage_key(&ctx.model.id, &oid);
177 let mut reader = HashingReader::new(reader);
178
179 if !storage
180 .client()
181 .exists(&key)
182 .await
183 .map_err(|e| GitError::Internal(format!("storage exists: {e}")))?
184 {
185 let sent = storage
186 .client()
187 .put_stream(
188 &key,
189 &mut reader,
190 content_type
191 .as_deref()
192 .unwrap_or("application/octet-stream"),
193 )
194 .await
195 .map_err(|e| GitError::Internal(format!("lfs put_stream: {e}")))?;
196 let (hex, _count) = reader.finish();
197 if sent as i64 != size {
198 let _ = storage.client().delete(&key).await;
199 return Err(GitError::InvalidArgument(format!(
200 "size mismatch: declared {size}, received {sent}"
201 )));
202 }
203 let actual = format!("sha256:{hex}");
204 if actual != oid {
205 let _ = storage.client().delete(&key).await;
206 return Err(GitError::InvalidArgument(format!(
207 "oid mismatch: expected {oid}, got {actual}"
208 )));
209 }
210 }
211 match git_lfs::Entity::insert(git_lfs::ActiveModel {
212 id: Set(Uuid::now_v7()),
213 repo_id: Set(ctx.model.id),
214 oid: Set(oid),
215 size: Set(size),
216 content_type: Set(content_type),
217 created_at: Set(OffsetDateTime::now_utc()),
218 })
219 .exec(svc.db_write())
220 .await
221 {
222 Ok(_) => {}
223 Err(db) => match GitError::from(db) {
224 GitError::Conflict(_) => {}
225 other => return Err(other),
226 },
227 }
228 repos::Entity::update_many()
229 .col_expr(
230 repos::Column::LfsSize,
231 Expr::col(repos::Column::LfsSize).add(size),
232 )
233 .col_expr(
234 repos::Column::UpdatedAt,
235 Expr::val(OffsetDateTime::now_utc()),
236 )
237 .filter(repos::Column::Id.eq(ctx.model.id))
238 .exec(svc.db_write())
239 .await?;
240 Ok(true)
241}
242
243/// In-memory convenience wrapper over [`upload_stream`] for small bodies.
244#[allow(clippy::too_many_arguments)]
245pub async fn upload(
246 svc: &GitService,
247 space_name: String,
248 repo_name: String,
249 caller: Uuid,
250 oid: String,
251 size: i64,
252 content_type: Option<String>,
253 body: Vec<u8>,
254) -> Result<bool, GitError> {
255 upload_stream(
256 svc,
257 space_name,
258 repo_name,
259 caller,
260 oid,
261 size,
262 content_type,
263 std::io::Cursor::new(body),
264 )
265 .await
266}
267
268/// `POST objects/verify` — confirm a stored object matches the recorded
269/// `(oid, size)`; fails with `NotFound` when the object or its DB row is
270/// missing.
271pub async fn verify(
272 svc: &GitService,
273 space_name: String,
274 repo_name: String,
275 req: LfsVerifyRequest,
276) -> Result<(), GitError> {
277 let oid = canonical_oid(&req.oid)?;
278 let ctx = svc.context(space_name, repo_name).await?;
279 let row = git_lfs::Entity::find()
280 .filter(git_lfs::Column::RepoId.eq(ctx.model.id))
281 .filter(git_lfs::Column::Oid.eq(&oid))
282 .one(svc.db_read())
283 .await?
284 .ok_or(GitError::NotFound)?;
285 if row.size != req.size {
286 return Err(GitError::InvalidArgument("size mismatch".to_string()));
287 }
288 let storage: &AppStorage = &svc.state.storage;
289 let present = storage
290 .client()
291 .exists(&storage_key(&ctx.model.id, &oid))
292 .await
293 .map_err(|e| GitError::Internal(format!("storage exists: {e}")))?;
294 if !present {
295 return Err(GitError::NotFound);
296 }
297 Ok(())
298}
299
300/// Stream an object into `writer` (bounded memory, no whole-object buffering).
301/// Returns the number of bytes written.
302pub async fn download_to<W>(
303 svc: &GitService,
304 space_name: String,
305 repo_name: String,
306 oid: String,
307 writer: &mut W,
308) -> Result<u64, GitError>
309where
310 W: tokio::io::AsyncWrite + Send + Unpin,
311{
312 let oid = canonical_oid(&oid)?;
313 let ctx = svc.context(space_name, repo_name).await?;
314 let storage: &AppStorage = &svc.state.storage;
315 storage
316 .client()
317 .get_to_writer(&storage_key(&ctx.model.id, &oid), writer)
318 .await
319 .map_err(|e| GitError::Internal(format!("lfs get: {e}")))
320}
321
322/// Whole-object convenience wrapper over [`download_to`] (use only for small
323/// objects; prefer the streaming variant in hot paths).
324pub async fn download(
325 svc: &GitService,
326 space_name: String,
327 repo_name: String,
328 oid: String,
329) -> Result<Vec<u8>, GitError> {
330 let mut buf = Vec::new();
331 download_to(svc, space_name, repo_name, oid, &mut buf).await?;
332 Ok(buf)
333}
334
335/// Consistency repair + unreferenced-object sweep for one repo:
336///
337/// 1. drop `git_lfs` rows whose storage object has vanished;
338/// 2. delete storage keys under `lfs/{repo}/` that have no DB row.
339///
340/// Returns the number of removed entries.
341pub async fn cleanup_unreferenced(
342 svc: &GitService,
343 space_name: String,
344 repo_name: String,
345) -> Result<i64, GitError> {
346 let ctx = svc.context(space_name, repo_name).await?;
347 let repo_id = ctx.model.id;
348 let storage: &AppStorage = &svc.state.storage;
349 let mut removed: i64 = 0;
350 let db = svc.db_write();
351
352 let rows = git_lfs::Entity::find()
353 .filter(git_lfs::Column::RepoId.eq(repo_id))
354 .all(db)
355 .await?;
356 for row in rows {
357 let key = storage_key(&repo_id, &row.oid);
358 let exists = storage
359 .client()
360 .exists(&key)
361 .await
362 .map_err(|e| GitError::Internal(format!("storage exists: {e}")))?;
363 if !exists {
364 git_lfs::Entity::delete_by_id(row.id).exec(db).await?;
365 removed += 1;
366 }
367 }
368
369 let prefix = format!("lfs/{}/", repo_id.simple());
370 let live: std::collections::HashSet<String> = git_lfs::Entity::find()
371 .filter(git_lfs::Column::RepoId.eq(repo_id))
372 .all(svc.db_read())
373 .await?
374 .into_iter()
375 .map(|r| r.oid)
376 .collect();
377 let mut token: Option<String> = None;
378 loop {
379 let page = storage
380 .client()
381 .list_page(&prefix, token.as_deref(), Some(200))
382 .await
383 .map_err(|e| GitError::Internal(format!("storage list: {e}")))?;
384 for obj in page.objects {
385 let Some(oid) = obj.key.strip_prefix(&prefix) else {
386 continue;
387 };
388 if !live.contains(oid) {
389 storage
390 .client()
391 .delete(&obj.key)
392 .await
393 .map_err(|e| GitError::Internal(format!("storage delete: {e}")))?;
394 removed += 1;
395 }
396 }
397 match page.next_continuation_token {
398 Some(next) if !next.is_empty() => token = Some(next),
399 _ => break,
400 }
401 }
402 Ok(removed)
403}
404
405fn object_error(oid: String, size: i64, err: GitError) -> LfsBatchObject {
406 LfsBatchObject {
407 oid,
408 size,
409 actions: HashMap::new(),
410 error: Some(LfsError {
411 code: crate::lfs::error::http_status(&err) as i32,
412 message: err.to_string(),
413 }),
414 }
415}
416
417fn storage_key(repo_id: &Uuid, oid: &str) -> String {
418 format!("lfs/{}/{}", repo_id.simple(), oid)
419}
420
421/// Tee between the upload reader and storage: every byte read is fed into a
422/// running SHA-256 and counted, so the upload can be verified without
423/// buffering the whole body.
424struct HashingReader<R> {
425 inner: R,
426 hasher: Sha256,
427 count: u64,
428 finished: bool,
429}
430
431impl<R> HashingReader<R> {
432 fn new(inner: R) -> Self {
433 Self {
434 inner,
435 hasher: Sha256::new(),
436 count: 0,
437 finished: false,
438 }
439 }
440
441 /// Returns `(sha256_hex, bytes_read)`; panics if the stream was not fully
442 /// consumed (which the storage layer guarantees on success).
443 fn finish(self) -> (String, u64) {
444 assert!(self.finished, "reader not fully consumed");
445 let hex = self
446 .hasher
447 .finalize()
448 .iter()
449 .map(|b| format!("{b:02x}"))
450 .collect();
451 (hex, self.count)
452 }
453}
454
455impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for HashingReader<R> {
456 fn poll_read(
457 mut self: Pin<&mut Self>,
458 cx: &mut std::task::Context<'_>,
459 buf: &mut tokio::io::ReadBuf<'_>,
460 ) -> Poll<std::io::Result<()>> {
461 let before = buf.filled().len();
462 let poll = Pin::new(&mut self.inner).poll_read(cx, buf);
463 if poll.is_ready() {
464 let written = &buf.filled()[before..];
465 if written.is_empty() && buf.remaining() > 0 {
466 self.finished = true;
467 } else {
468 self.hasher.update(written);
469 self.count += written.len() as u64;
470 }
471 }
472 poll
473 }
474}
475
476fn now_plus_secs(secs: i64) -> String {
477 OffsetDateTime::now_utc()
478 .saturating_add(time::Duration::seconds(secs))
479 .format(&time::format_description::well_known::Rfc3339)
480 .unwrap_or_default()
481}
482
483/// The batch `verify` action always points back at this server (the initial
484/// upload itself goes directly to the presigned S3 URL). The href follows the
485/// Gitea-style `/{space}/{repo}.git/info/lfs` layout, prefixed with
486/// `web.web_base_url` when configured.
487fn verify_href(svc: &GitService, space_name: &str, repo_name: &str) -> String {
488 let base = svc.state.config.web.web_base_url.trim_end_matches('/');
489 if base.is_empty() {
490 format!("/{}/{}.git/info/lfs/objects/verify", space_name, repo_name)
491 } else {
492 format!(
493 "{base}/{}/{}.git/info/lfs/objects/verify",
494 space_name, repo_name
495 )
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use std::io::Cursor;
503
504 fn expected_hex(data: &[u8]) -> String {
505 Sha256::digest(data)
506 .iter()
507 .map(|b| format!("{b:02x}"))
508 .collect()
509 }
510
511 #[tokio::test]
512 async fn hashing_reader_hashes_and_counts_all_bytes() {
513 let data = b"stream me, stream me, stream me!";
514 let mut reader = HashingReader::new(Cursor::new(data));
515 let mut buf = Vec::new();
516 tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buf)
517 .await
518 .expect("read");
519 assert_eq!(buf, data);
520 let (hex, count) = reader.finish();
521 assert_eq!(hex, expected_hex(data));
522 assert_eq!(count, data.len() as u64);
523 }
524
525 #[tokio::test]
526 async fn hashing_reader_works_across_small_buffers() {
527 let data = (0..100_000u32).map(|i| (i % 251) as u8).collect::<Vec<_>>();
528 let mut reader = HashingReader::new(Cursor::new(&data));
529 let mut chunk = [0u8; 7];
530 let mut seen = 0usize;
531 loop {
532 let n = tokio::io::AsyncReadExt::read(&mut reader, &mut chunk)
533 .await
534 .expect("read");
535 if n == 0 {
536 break;
537 }
538 assert_eq!(&chunk[..n], &data[seen..seen + n]);
539 seen += n;
540 }
541 let (hex, count) = reader.finish();
542 assert_eq!(hex, expected_hex(&data));
543 assert_eq!(count, data.len() as u64);
544 }
545
546 #[test]
547 fn storage_key_uses_repo_id_and_canonical_oid() {
548 let id = Uuid::now_v7();
549 let key = storage_key(&id, "sha256:abcd");
550 assert_eq!(key, format!("lfs/{}/sha256:abcd", id.simple()));
551 }
552}
Added lib/git/src/lfs/types.rs View lib/git/src/lfs/types.rs
@@ -0,0 +1,223 @@
1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5/// Git LFS API v1 protocol types.
6///
7/// Wire shapes follow https://github.com/git-lfs/git-lfs/blob/main/docs/api/
8/// so the future HTTP layer can serialize responses verbatim:
9///
10/// - batch: `objects/batch`, `objects/{oid}`, `objects/verify`
11/// - locks: `locks`, `locks/verify`, `locks/{id}/unlock`
12///
13/// Locks use `locked_at` + `owner { name }` (not the internal `created_at` /
14/// raw owner id).
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum LfsOperation {
19 Upload,
20 Download,
21 Verify,
22}
23
24/// Optional ref carried by batch / lock requests.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct LfsRef {
27 pub name: String,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct LfsObject {
32 pub oid: String,
33 pub size: i64,
34 #[serde(default)]
35 pub content_type: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct LfsBatchRequest {
40 pub operation: LfsOperation,
41 #[serde(default)]
42 pub transfers: Vec<String>,
43 #[serde(default, rename = "ref")]
44 pub r#ref: Option<LfsRef>,
45 #[serde(default)]
46 pub hash_algo: Option<String>,
47 pub objects: Vec<LfsObject>,
48}
49
50#[derive(Debug, Clone, Serialize)]
51pub struct LfsAction {
52 pub href: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub header: Option<HashMap<String, String>>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub expires_at: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct LfsError {
61 pub code: i32,
62 pub message: String,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct LfsBatchObject {
67 pub oid: String,
68 pub size: i64,
69 #[serde(skip_serializing_if = "HashMap::is_empty")]
70 pub actions: HashMap<String, LfsAction>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub error: Option<LfsError>,
73}
74
75#[derive(Debug, Clone, Serialize)]
76pub struct LfsBatchResponse {
77 pub transfer: String,
78 pub hash_algo: String,
79 pub objects: Vec<LfsBatchObject>,
80}
81
82/// Payload of `POST objects/verify`.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct LfsVerifyRequest {
85 pub oid: String,
86 pub size: i64,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct LfsLockOwner {
91 pub name: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct LfsLock {
96 pub id: String,
97 pub path: String,
98 pub locked_at: String,
99 pub owner: LfsLockOwner,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct LfsLockCreateRequest {
104 pub path: String,
105 #[serde(default, rename = "ref")]
106 pub r#ref: Option<LfsRef>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct LfsLockListResponse {
111 #[serde(default)]
112 pub locks: Vec<LfsLock>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub next_cursor: Option<String>,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct LfsVerifyLocksRequest {
119 #[serde(default)]
120 pub cursor: Option<String>,
121 #[serde(default)]
122 pub limit: Option<u32>,
123}
124
125#[derive(Debug, Clone, Serialize)]
126pub struct LfsVerifyLocksResponse {
127 #[serde(default)]
128 pub ours: Vec<LfsLock>,
129 #[serde(default)]
130 pub theirs: Vec<LfsLock>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub next_cursor: Option<String>,
133}
134
135#[derive(Debug, Clone, Serialize)]
136pub struct LfsUnlockResponse {
137 pub lock: LfsLock,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct LfsUnlockRequest {
142 #[serde(default)]
143 pub force: bool,
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn batch_request_parses_ref_and_operation() {
152 let raw = r#"{
153 "operation": "upload",
154 "transfers": ["basic"],
155 "ref": {"name": "refs/heads/main"},
156 "objects": [{"oid": "sha256:abcd", "size": 10}]
157 }"#;
158 let req: LfsBatchRequest = serde_json::from_str(raw).expect("parse");
159 assert_eq!(req.operation, LfsOperation::Upload);
160 assert_eq!(req.r#ref.as_ref().unwrap().name, "refs/heads/main");
161 assert_eq!(req.objects[0].oid, "sha256:abcd");
162 assert!(req.hash_algo.is_none());
163 }
164
165 #[test]
166 fn batch_response_is_serialized_in_spec_shape() {
167 let mut actions = HashMap::new();
168 actions.insert(
169 "download".to_string(),
170 LfsAction {
171 href: "https://s3/x".to_string(),
172 header: None,
173 expires_at: Some("2026-01-01T00:00:00Z".to_string()),
174 },
175 );
176 let resp = LfsBatchResponse {
177 transfer: "basic".to_string(),
178 hash_algo: "sha256".to_string(),
179 objects: vec![LfsBatchObject {
180 oid: "sha256:abcd".to_string(),
181 size: 10,
182 actions,
183 error: None,
184 }],
185 };
186 let json = serde_json::to_value(&resp).expect("serialize");
187 assert_eq!(json["transfer"], "basic");
188 assert_eq!(json["hash_algo"], "sha256");
189 assert_eq!(
190 json["objects"][0]["actions"]["download"]["href"],
191 "https://s3/x"
192 );
193 assert_eq!(
194 json["objects"][0]["actions"]["download"]["expires_at"],
195 "2026-01-01T00:00:00Z"
196 );
197 }
198
199 #[test]
200 fn lock_is_serialized_with_locked_at_and_owner_name() {
201 let lock = LfsLock {
202 id: "889c9d1b-7dc1-4e0f-a5a8-4f8ad7d2d9a5".to_string(),
203 path: "data.bin".to_string(),
204 locked_at: "2026-01-01T00:00:00Z".to_string(),
205 owner: LfsLockOwner {
206 name: "alice".to_string(),
207 },
208 };
209 let json = serde_json::to_value(&lock).expect("serialize");
210 assert_eq!(json["locked_at"], "2026-01-01T00:00:00Z");
211 assert_eq!(json["owner"]["name"], "alice");
212 assert!(json.get("created_at").is_none());
213 assert!(json.get("owner_id").is_none());
214 }
215
216 #[test]
217 fn lock_unlock_request_parses_force() {
218 let force: LfsUnlockRequest = serde_json::from_str(r#"{"force": true}"#).expect("parse");
219 assert!(force.force);
220 let default: LfsUnlockRequest = serde_json::from_str(r#"{}"#).expect("parse");
221 assert!(!default.force);
222 }
223}
Added lib/git/src/lfs/validate.rs View lib/git/src/lfs/validate.rs
@@ -0,0 +1,123 @@
1use crate::share::errors::GitError;
2
3/// Maximum length of a lock path (git-lfs enforces a similar cap client-side).
4pub const MAX_LOCK_PATH_LEN: usize = 1024;
5
6/// Normalize a client-supplied oid into the canonical form used as the
7/// storage key / DB value: lowercase `sha256:<64 hex>`.
8///
9/// Both the spec form (`sha256:<hex>`) and the bare 64-hex form accepted by
10/// some servers/clients are accepted.
11pub fn canonical_oid(raw: &str) -> Result<String, GitError> {
12 let raw = raw.trim();
13 let hex = match raw.strip_prefix("sha256:") {
14 Some(hex) => hex,
15 None if raw.len() == 64 => raw,
16 None => return Err(oid_error(raw)),
17 };
18 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
19 return Err(oid_error(raw));
20 }
21 let mut out = String::with_capacity(70);
22 out.push_str("sha256:");
23 for c in hex.bytes() {
24 out.push(c.to_ascii_lowercase() as char);
25 }
26 Ok(out)
27}
28
29pub fn is_canonical_oid(oid: &str) -> bool {
30 oid.len() == 71 && oid.starts_with("sha256:") && oid[7..].bytes().all(|b| b.is_ascii_hexdigit())
31}
32
33/// Reject zero/negative sizes and anything above the configured cap.
34pub fn validate_size(size: i64, cap_bytes: u64) -> Result<(), GitError> {
35 if size < 0 {
36 return Err(GitError::InvalidArgument(format!("invalid size {size}")));
37 }
38 let bytes = size as u64;
39 if bytes > cap_bytes {
40 return Err(GitError::InvalidArgument(format!(
41 "object size {bytes} exceeds limit {cap_bytes}"
42 )));
43 }
44 Ok(())
45}
46
47/// Locks paths must be non-empty, NUL-free and within a sane length.
48pub fn validate_lock_path(path: &str) -> Result<&str, GitError> {
49 let path = path.trim();
50 if path.is_empty() {
51 return Err(GitError::InvalidArgument(
52 "lock path must not be empty".to_string(),
53 ));
54 }
55 if path.len() > MAX_LOCK_PATH_LEN {
56 return Err(GitError::InvalidArgument(format!(
57 "lock path exceeds {MAX_LOCK_PATH_LEN} chars"
58 )));
59 }
60 if path.contains('\0') {
61 return Err(GitError::InvalidArgument(
62 "lock path contains NUL".to_string(),
63 ));
64 }
65 Ok(path)
66}
67
68fn oid_error(raw: &str) -> GitError {
69 GitError::InvalidArgument(format!("invalid oid `{raw}` (expected sha256:<64 hex>)"))
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 #[test]
77 fn canonicalizes_spec_and_bare_oids() {
78 let hex = "a".repeat(64);
79 assert_eq!(
80 canonical_oid(&format!("sha256:{hex}")).unwrap(),
81 format!("sha256:{hex}")
82 );
83 assert_eq!(canonical_oid(&hex).unwrap(), format!("sha256:{hex}"));
84 let upper = "A".repeat(64);
85 assert_eq!(
86 canonical_oid(&format!("sha256:{upper}")).unwrap(),
87 format!("sha256:{}", "a".repeat(64))
88 );
89 }
90
91 #[test]
92 fn rejects_bad_oids() {
93 assert!(canonical_oid("").is_err());
94 assert!(canonical_oid("sha1:abcd").is_err());
95 assert!(canonical_oid("sha256:xyz").is_err());
96 assert!(canonical_oid(&"a".repeat(63)).is_err());
97 assert!(canonical_oid(&format!("sha256:{}g", "a".repeat(63))).is_err());
98 assert!(canonical_oid(&format!("sha256:{}", "a".repeat(65))).is_err());
99 }
100
101 #[test]
102 fn is_canonical_checks_exact_shape() {
103 assert!(is_canonical_oid(&format!("sha256:{}", "a".repeat(64))));
104 assert!(!is_canonical_oid(&"a".repeat(64)));
105 assert!(!is_canonical_oid(&format!("SHA256:{}", "a".repeat(64))));
106 }
107
108 #[test]
109 fn size_validation_respects_cap() {
110 assert!(validate_size(0, 100).is_ok());
111 assert!(validate_size(100, 100).is_ok());
112 assert!(validate_size(101, 100).is_err());
113 assert!(validate_size(-1, 100).is_err());
114 }
115
116 #[test]
117 fn lock_path_validation() {
118 assert_eq!(validate_lock_path(" a/b.png ").unwrap(), "a/b.png");
119 assert!(validate_lock_path(" ").is_err());
120 assert!(validate_lock_path(&"a".repeat(1025)).is_err());
121 assert!(validate_lock_path("a\0b").is_err());
122 }
123}
Added lib/git/src/service/analysis.rs View lib/git/src/service/analysis.rs
@@ -0,0 +1,281 @@
1use std::collections::HashMap;
2
3use gitbaby::commit::types::{ListCommitsOptions, TreeEntryMode};
4use models::git::git_languages;
5use models::repo::repos;
6use sea_orm::{
7 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, Unchanged,
8};
9use serde::Serialize;
10use time::OffsetDateTime;
11use uuid::Uuid;
12
13use crate::service::GitService;
14use crate::share::errors::{GitError, map_baby};
15
16#[derive(Debug, Clone, Serialize)]
17pub struct LanguageStatDto {
18 pub language: String,
19 pub percentage_bp: i32,
20 pub size: i64,
21 pub is_primary: bool,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct ContributorStatDto {
26 pub name: String,
27 pub email: String,
28 pub commits: i64,
29 pub additions: i64,
30 pub deletions: i64,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct CodeActivityAuthorDto {
35 pub name: String,
36 pub email: String,
37 pub commits: i64,
38 pub additions: i64,
39 pub deletions: i64,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct CodeActivityDto {
44 pub authors: Vec<CodeActivityAuthorDto>,
45 pub total_commits: i64,
46 pub total_additions: i64,
47 pub total_deletions: i64,
48}
49
50pub fn ext_to_language(name: &str) -> Option<&'static str> {
51 let base = name.rsplit('/').next().unwrap_or(name);
52 match base {
53 "Dockerfile" => return Some("Dockerfile"),
54 "Makefile" => return Some("Makefile"),
55 "Gemfile" => return Some("Ruby"),
56 _ => {}
57 }
58 let ext = base.rsplit('.').next()?.to_ascii_lowercase();
59 let lang = match ext.as_str() {
60 "rs" => "Rust",
61 "go" => "Go",
62 "py" => "Python",
63 "js" => "JavaScript",
64 "ts" => "TypeScript",
65 "tsx" => "TypeScript",
66 "jsx" => "JavaScript",
67 "java" => "Java",
68 "c" | "h" => "C",
69 "cpp" | "cc" | "hpp" => "C++",
70 "cs" => "C#",
71 "php" => "PHP",
72 "rb" => "Ruby",
73 "swift" => "Swift",
74 "kt" | "kts" => "Kotlin",
75 "scala" => "Scala",
76 "md" | "markdown" => "Markdown",
77 "yaml" | "yml" => "YAML",
78 "json" => "JSON",
79 "toml" => "TOML",
80 "xml" => "XML",
81 "html" | "htm" => "HTML",
82 "css" => "CSS",
83 "scss" | "sass" => "SCSS",
84 "sql" => "SQL",
85 "sh" | "bash" | "zsh" => "Shell",
86 "dockerfile" => "Dockerfile",
87 "proto" => "Protocol Buffers",
88 "vue" => "Vue",
89 "lua" => "Lua",
90 "dart" => "Dart",
91 _ => return None,
92 };
93 Some(lang)
94}
95
96impl GitService {
97 /// Recompute language breakdown from the tree at HEAD and persist into
98 /// `git_language` (percentage in basis points) + `repos.primary_language`.
99 pub async fn analysis_languages(
100 &self,
101 space_name: String,
102 repo_name: String,
103 ) -> Result<Vec<LanguageStatDto>, GitError> {
104 let ctx = self.context(space_name, repo_name).await?;
105 let baby = self.baby(&ctx);
106 let langs = language_stats(&baby).await?;
107 let total: i64 = langs.values().sum();
108 let mut rows: Vec<(String, i64)> = langs.into_iter().collect();
109 rows.sort_by_key(|(_, size)| std::cmp::Reverse(*size));
110
111 let db = self.db_write();
112 git_languages::Entity::delete_many()
113 .filter(git_languages::Column::RepoId.eq(ctx.model.id))
114 .exec(db)
115 .await?;
116 let now = OffsetDateTime::now_utc();
117 let primary = rows.first().map(|(name, _)| name.clone());
118 for (language, size) in &rows {
119 let bp = if total > 0 {
120 ((*size as f64 / total as f64) * 10_000.0).round() as i32
121 } else {
122 0
123 };
124 git_languages::ActiveModel {
125 id: Set(Uuid::now_v7()),
126 repo_id: Set(ctx.model.id),
127 language: Set(language.clone()),
128 percentage_bp: Set(bp),
129 size: Set(*size),
130 created_at: Set(now),
131 }
132 .insert(db)
133 .await?;
134 }
135 let am = repos::ActiveModel {
136 id: Unchanged(ctx.model.id),
137 primary_language: Set(primary),
138 updated_at: Set(now),
139 ..Default::default()
140 };
141 am.update(db).await?;
142
143 let primary_name = rows.first().map(|(n, _)| n.clone());
144 Ok(rows
145 .into_iter()
146 .map(|(language, size)| {
147 let is_primary = primary_name.as_deref() == Some(language.as_str());
148 LanguageStatDto {
149 language,
150 percentage_bp: if total > 0 {
151 ((size as f64 / total as f64) * 10_000.0).round() as i32
152 } else {
153 0
154 },
155 size,
156 is_primary,
157 }
158 })
159 .collect())
160 }
161
162 pub async fn analysis_languages_read(
163 &self,
164 space_name: String,
165 repo_name: String,
166 ) -> Result<Vec<LanguageStatDto>, GitError> {
167 let ctx = self.context(space_name, repo_name).await?;
168 let rows = git_languages::Entity::find()
169 .filter(git_languages::Column::RepoId.eq(ctx.model.id))
170 .order_by_desc(git_languages::Column::Size)
171 .all(self.db_read())
172 .await?;
173 Ok(rows
174 .into_iter()
175 .enumerate()
176 .map(|(i, r)| LanguageStatDto {
177 language: r.language,
178 percentage_bp: r.percentage_bp,
179 size: r.size,
180 is_primary: i == 0,
181 })
182 .collect())
183 }
184
185 pub async fn analysis_contributors(
186 &self,
187 space_name: String,
188 repo_name: String,
189 ) -> Result<Vec<ContributorStatDto>, GitError> {
190 let ctx = self.context(space_name, repo_name).await?;
191 let commits = self
192 .baby(&ctx)
193 .list_commits(ListCommitsOptions {
194 revision: "HEAD".to_string(),
195 ..Default::default()
196 })
197 .await
198 .map_err(map_baby)?;
199 let mut map: HashMap<(String, String), i64> = HashMap::new();
200 for c in &commits {
201 *map.entry((c.author.name.clone(), c.author.email.clone()))
202 .or_default() += 1;
203 }
204 let mut out: Vec<ContributorStatDto> = map
205 .into_iter()
206 .map(|((name, email), commits)| ContributorStatDto {
207 name,
208 email,
209 commits,
210 additions: 0,
211 deletions: 0,
212 })
213 .collect();
214 out.sort_by_key(|b| std::cmp::Reverse(b.commits));
215 Ok(out)
216 }
217
218 pub async fn analysis_code_activity(
219 &self,
220 space_name: String,
221 repo_name: String,
222 branch: Option<String>,
223 ) -> Result<CodeActivityDto, GitError> {
224 let ctx = self.context(space_name, repo_name).await?;
225 let revision = branch.unwrap_or_else(|| "HEAD".to_string());
226 let commits = self
227 .baby(&ctx)
228 .list_commits(ListCommitsOptions {
229 revision,
230 ..Default::default()
231 })
232 .await
233 .map_err(map_baby)?;
234 let mut authors: Vec<CodeActivityAuthorDto> = Vec::new();
235 for c in &commits {
236 match authors.iter_mut().find(|a| a.email == c.author.email) {
237 Some(a) => a.commits += 1,
238 None => authors.push(CodeActivityAuthorDto {
239 name: c.author.name.clone(),
240 email: c.author.email.clone(),
241 commits: 1,
242 additions: 0,
243 deletions: 0,
244 }),
245 }
246 }
247 authors.sort_by_key(|b| std::cmp::Reverse(b.commits));
248 let total = commits.len() as i64;
249 Ok(CodeActivityDto {
250 authors,
251 total_commits: total,
252 total_additions: 0,
253 total_deletions: 0,
254 })
255 }
256}
257
258pub(crate) async fn language_stats(
259 baby: &gitbaby::GitBaby,
260) -> Result<HashMap<String, i64>, GitError> {
261 let entries = baby
262 .walk_tree(
263 "HEAD",
264 std::path::Path::new(""),
265 gitbaby::tree::types::WalkTreeOptions {
266 recurse: true,
267 show_trees: false,
268 },
269 )
270 .await
271 .map_err(map_baby)?;
272 let mut map: HashMap<String, i64> = HashMap::new();
273 for e in entries {
274 if (e.mode == TreeEntryMode::Blob || e.mode == TreeEntryMode::Exec)
275 && let Some(lang) = ext_to_language(&e.name)
276 {
277 *map.entry(lang.to_string()).or_default() += e.size.unwrap_or(0) as i64;
278 }
279 }
280 Ok(map)
281}
Added lib/git/src/service/archives.rs View lib/git/src/service/archives.rs
@@ -0,0 +1,168 @@
1use std::path::PathBuf;
2use std::process::Command;
3
4use export::storage::AppStorage;
5use gitbaby::archive::types::{ArchiveFormat, CreateArchiveRequest};
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8
9use crate::service::GitService;
10use crate::share::errors::{GitError, map_baby};
11
12#[derive(Debug, Clone, Serialize)]
13pub struct ArchiveResultDto {
14 pub format: String,
15 pub size: i64,
16 pub cache_key: String,
17 pub download_url: Option<String>,
18}
19
20#[derive(Debug, Clone, Deserialize)]
21pub struct ArchiveOptionsDto {
22 pub format: ArchiveFormat,
23 #[serde(default)]
24 pub path_filter: Option<String>,
25}
26
27fn fmt_ext(f: ArchiveFormat) -> &'static str {
28 match f {
29 ArchiveFormat::Tar => "tar",
30 ArchiveFormat::TarGz => "tar.gz",
31 ArchiveFormat::TarBz2 => "tar.bz2",
32 ArchiveFormat::Zip => "zip",
33 }
34}
35
36impl GitService {
37 pub async fn archive_create(
38 &self,
39 space_name: String,
40 repo_name: String,
41 revision: String,
42 opts: ArchiveOptionsDto,
43 ) -> Result<ArchiveResultDto, GitError> {
44 let ctx = self.context(space_name, repo_name).await?;
45 let baby = self.baby(&ctx);
46 let ext = fmt_ext(opts.format);
47 let prefix_filter = opts.path_filter.clone();
48 let req = CreateArchiveRequest {
49 commit: revision.clone(),
50 format: opts.format,
51 prefix: Some(format!("{}-", ctx.model.id.simple())),
52 paths: prefix_filter.into_iter().collect(),
53 };
54 baby.create_archive(req).await.map_err(map_baby)?;
55
56 let key = format!("git/{}/{}.{}", ctx.model.id.simple(), revision, ext);
57 let out_dir = ctx.repo_dir();
58 let storage: &AppStorage = &self.state.storage;
59 let bytes = read_archive_bytes(&out_dir, &revision, ext)?;
60 let bytes_len = bytes.len() as i64;
61 storage
62 .client()
63 .put(&key, &bytes)
64 .await
65 .map_err(|e| GitError::Internal(format!("archive put failed: {e}")))?;
66 let download_url = storage.client().presign_get(&key, 3600).ok();
67
68 Ok(ArchiveResultDto {
69 format: ext.to_string(),
70 size: bytes_len,
71 cache_key: key,
72 download_url,
73 })
74 }
75
76 pub async fn archive_download(
77 &self,
78 space_name: String,
79 repo_name: String,
80 revision: String,
81 opts: ArchiveOptionsDto,
82 ) -> Result<ArchiveResultDto, GitError> {
83 let ctx = self.context(space_name, repo_name).await?;
84 let ext = fmt_ext(opts.format);
85 let key = format!("git/{}/{}.{}", ctx.model.id.simple(), revision, ext);
86 let storage: &AppStorage = &self.state.storage;
87 let exists = storage
88 .client()
89 .exists(&key)
90 .await
91 .map_err(|e| GitError::Internal(format!("storage exists: {e}")))?;
92 if !exists {
93 return Err(GitError::NotFound);
94 }
95 let download_url = storage.client().presign_get(&key, 3600).ok();
96 Ok(ArchiveResultDto {
97 format: ext.to_string(),
98 size: -1,
99 cache_key: key,
100 download_url,
101 })
102 }
103
104 pub async fn archive_create_bundle(
105 &self,
106 space_name: String,
107 repo_name: String,
108 ) -> Result<ArchiveResultDto, GitError> {
109 let ctx = self.context(space_name, repo_name).await?;
110 let bundle_path = std::env::temp_dir().join(format!(
111 "gitdata-bundle-{}-{}",
112 std::process::id(),
113 OffsetDateTime::now_utc().unix_timestamp_nanos()
114 ));
115 let out = Command::new("git")
116 .arg("-C")
117 .arg(ctx.repo_dir())
118 .args([
119 "bundle",
120 "create",
121 bundle_path.to_str().unwrap_or("bundle.git"),
122 "--all",
123 ])
124 .output()
125 .map_err(|e| GitError::Internal(format!("bundle create: {e}")))?;
126 if !out.status.success() {
127 return Err(GitError::Internal(format!(
128 "bundle failed: {}",
129 String::from_utf8_lossy(&out.stderr).trim()
130 )));
131 }
132 let key = format!("git/{}/bundle.git", ctx.model.id.simple());
133 let storage: &AppStorage = &self.state.storage;
134 let bytes = std::fs::read(&bundle_path)
135 .map_err(|e| GitError::Internal(format!("bundle read: {e}")))?;
136 let bytes_len = bytes.len() as i64;
137 storage
138 .client()
139 .put(&key, &bytes)
140 .await
141 .map_err(|e| GitError::Internal(format!("bundle put failed: {e}")))?;
142 let download_url = storage.client().presign_get(&key, 3600).ok();
143 let _ = std::fs::remove_file(&bundle_path);
144 Ok(ArchiveResultDto {
145 format: "bundle".to_string(),
146 size: bytes_len,
147 cache_key: key,
148 download_url,
149 })
150 }
151}
152
153fn read_archive_bytes(repo_dir: &PathBuf, rev: &str, ext: &str) -> Result<Vec<u8>, GitError> {
154 use std::process::Command;
155 let out = Command::new("git")
156 .arg("-C")
157 .arg(repo_dir)
158 .args(["archive", "--format", ext, rev])
159 .output()
160 .map_err(|e| GitError::Internal(format!("archive read failed: {e}")))?;
161 if !out.status.success() {
162 return Err(GitError::Internal(format!(
163 "archive failed: {}",
164 String::from_utf8_lossy(&out.stderr).trim()
165 )));
166 }
167 Ok(out.stdout)
168}
Added lib/git/src/service/blame.rs View lib/git/src/service/blame.rs
@@ -0,0 +1,69 @@
1use gitbaby::blame::types::{BlameHunk, BlameOptions, Range};
2use serde::Serialize;
3
4use crate::service::GitService;
5use crate::share::errors::{GitError, map_baby};
6
7#[derive(Debug, Clone, Serialize)]
8pub struct BlameRangeDto {
9 pub starting_line: i32,
10 pub ending_line: i32,
11 pub commit_sha: String,
12 pub abbreviated_oid: String,
13 pub path: Option<String>,
14}
15
16impl From<&BlameHunk> for BlameRangeDto {
17 fn from(h: &BlameHunk) -> Self {
18 let hex = h.sha.to_string();
19 let starting = h.final_start_line as i32;
20 let ending = (h.final_start_line as i32 + h.final_line_count as i32).saturating_sub(1);
21 Self {
22 starting_line: starting,
23 ending_line: ending,
24 commit_sha: hex.clone(),
25 abbreviated_oid: hex.get(..7).unwrap_or("").to_string(),
26 path: h
27 .previous_path
28 .clone()
29 .map(|p| p.to_string_lossy().into_owned()),
30 }
31 }
32}
33
34impl GitService {
35 pub async fn blame(
36 &self,
37 space_name: String,
38 repo_name: String,
39 revision: String,
40 path: std::path::PathBuf,
41 range_start: Option<u32>,
42 range_end: Option<u32>,
43 ) -> Result<Vec<BlameRangeDto>, GitError> {
44 if path.as_os_str().is_empty()
45 || path.is_absolute()
46 || path
47 .components()
48 .any(|c| matches!(c, std::path::Component::ParentDir))
49 {
50 return Err(GitError::InvalidArgument(format!(
51 "invalid blame path `{}`",
52 path.display()
53 )));
54 }
55 let ctx = self.context(space_name, repo_name).await?;
56 let opts = BlameOptions {
57 revision,
58 path,
59 range: match (range_start, range_end) {
60 (Some(s), Some(e)) if s <= e => Some(Range { start: s, end: e }),
61 _ => None,
62 },
63 ignore_revs_file: None,
64 bypass_ignore: false,
65 };
66 let hunks = self.baby(&ctx).blame_all(opts).await.map_err(map_baby)?;
67 Ok(hunks.iter().map(BlameRangeDto::from).collect())
68 }
69}
Added lib/git/src/service/blobs.rs View lib/git/src/service/blobs.rs
@@ -0,0 +1,186 @@
1use gix::ObjectId;
2
3use crate::service::GitService;
4use crate::share::errors::{GitError, map_baby};
5
6pub const LFS_POINTER_KEYWORD: &str = "https://git-lfs.github.com/spec/v1";
7
8pub const SNIFF_LIMIT: i64 = 8192;
9
10#[derive(Debug, Clone, serde::Serialize)]
11pub struct LfsPointerDto {
12 pub version: String,
13 pub oid: Option<String>,
14 pub size: Option<i64>,
15}
16
17#[derive(Debug, Clone, serde::Serialize)]
18pub struct BlobMetaDto {
19 pub oid: String,
20 pub size: i64,
21 pub is_binary: bool,
22 pub is_lfs_pointer: bool,
23 pub lfs: Option<LfsPointerDto>,
24}
25
26#[derive(Debug, Clone, serde::Serialize)]
27pub struct BlobContentDto {
28 pub oid: String,
29 pub data: Vec<u8>,
30 pub size: i64,
31 pub is_binary: bool,
32 pub truncated: bool,
33}
34
35#[derive(Debug, Clone, serde::Serialize)]
36pub struct RawDownloadDto {
37 pub id: String,
38 pub size: i64,
39 pub is_lfs_pointer: bool,
40}
41
42pub fn is_binary(data: &[u8]) -> bool {
43 let sniff = data.iter().take(SNIFF_LIMIT as usize);
44 sniff.step_by(2).any(|&b| b == 0)
45}
46
47pub fn parse_lfs_pointer(data: &[u8]) -> Option<LfsPointerDto> {
48 let text = String::from_utf8_lossy(data).to_string();
49 let mut version = None;
50 let mut oid = None;
51 let mut size = None;
52 for line in text.lines().take(8) {
53 if let Some(v) = line.strip_prefix("version ") {
54 version = Some(v.to_string());
55 } else if let Some(v) = line.strip_prefix("oid sha256:") {
56 oid = Some(v.trim().to_string());
57 } else if let Some(v) = line.strip_prefix("size ") {
58 size = v.trim().parse::<i64>().ok();
59 }
60 }
61 match version {
62 Some(v) if v == LFS_POINTER_KEYWORD => Some(LfsPointerDto {
63 version: v,
64 oid,
65 size,
66 }),
67 _ => None,
68 }
69}
70
71impl GitService {
72 async fn blob_sniff(
73 &self,
74 ctx: &crate::context::facade::GitContext,
75 oid: &ObjectId,
76 ) -> Result<(i64, Vec<u8>), GitError> {
77 let baby = self.baby(ctx);
78 let size = baby.get_blob_size(*oid).await.map_err(map_baby)?;
79 let blob = baby
80 .get_blob(
81 *oid,
82 gitbaby::blob::types::GetBlobOptions {
83 limit: Some(SNIFF_LIMIT),
84 },
85 )
86 .await
87 .map_err(map_baby)?;
88 Ok((size, blob.data))
89 }
90
91 pub async fn blob_metadata(
92 &self,
93 space_name: String,
94 repo_name: String,
95 oid: String,
96 ) -> Result<BlobMetaDto, GitError> {
97 let object_id = parse_oid(&oid)?;
98 let ctx = self.context(space_name, repo_name).await?;
99 let (size, sniff) = self.blob_sniff(&ctx, &object_id).await?;
100 let lfs = parse_lfs_pointer(&sniff);
101 Ok(BlobMetaDto {
102 oid,
103 size,
104 is_binary: is_binary(&sniff),
105 is_lfs_pointer: lfs.is_some(),
106 lfs,
107 })
108 }
109
110 pub async fn blob_content(
111 &self,
112 space_name: String,
113 repo_name: String,
114 oid: String,
115 ) -> Result<BlobContentDto, GitError> {
116 let object_id = parse_oid(&oid)?;
117 let ctx = self.context(space_name, repo_name).await?;
118 let baby = self.baby(&ctx);
119 let size = baby.get_blob_size(object_id).await.map_err(map_baby)?;
120 let max_blob = self.state.config.git.max_blob_size;
121 let truncated = size > max_blob as i64;
122 let limit = if truncated {
123 Some(max_blob as i64)
124 } else {
125 None
126 };
127 let blob = baby
128 .get_blob(object_id, gitbaby::blob::types::GetBlobOptions { limit })
129 .await
130 .map_err(map_baby)?;
131 Ok(BlobContentDto {
132 oid,
133 size,
134 is_binary: is_binary(&blob.data),
135 data: blob.data,
136 truncated,
137 })
138 }
139
140 pub async fn blob_raw_download(
141 &self,
142 space_name: String,
143 repo_name: String,
144 oid: String,
145 ) -> Result<RawDownloadDto, GitError> {
146 let object_id = parse_oid(&oid)?;
147 let ctx = self.context(space_name, repo_name).await?;
148 let (size, sniff) = self.blob_sniff(&ctx, &object_id).await?;
149 Ok(RawDownloadDto {
150 id: oid,
151 size,
152 is_lfs_pointer: parse_lfs_pointer(&sniff).is_some(),
153 })
154 }
155
156 pub async fn list_lfs_pointers(
157 &self,
158 space_name: String,
159 repo_name: String,
160 revision: String,
161 limit: Option<u32>,
162 ) -> Result<Vec<LfsPointerDto>, GitError> {
163 let ctx = self.context(space_name, repo_name).await?;
164 let baby = self.baby(&ctx);
165 let pointers = baby
166 .list_lfs_pointers(gitbaby::blob::types::ListLFSPointersOptions {
167 revisions: vec![revision],
168 limit,
169 })
170 .await
171 .map_err(map_baby)?;
172 Ok(pointers
173 .into_iter()
174 .map(|p| LfsPointerDto {
175 version: LFS_POINTER_KEYWORD.to_string(),
176 oid: Some(p.oid.to_string()),
177 size: Some(p.size),
178 })
179 .collect())
180 }
181}
182
183pub fn parse_oid(hex: &str) -> Result<ObjectId, GitError> {
184 ObjectId::from_hex(hex.trim().as_bytes())
185 .map_err(|_| GitError::InvalidArgument(format!("invalid object id `{hex}`")))
186}
Added lib/git/src/service/branches.rs View lib/git/src/service/branches.rs
@@ -0,0 +1,294 @@
1use std::collections::HashMap;
2
3use gitbaby::branch::ListBranchesOptions;
4use models::git::git_branches;
5use models::repo::repos;
6use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, Unchanged};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9
10use crate::context::facade::GitContext;
11use crate::service::GitService;
12use crate::share::errors::{GitError, map_baby};
13use crate::share::policy::load_branch_policy;
14use crate::share::validate::validate_branch_name;
15
16#[derive(Debug, Clone, Serialize)]
17pub struct BranchDto {
18 pub name: String,
19 pub commit_sha: String,
20 pub is_protected: bool,
21 pub is_deleted: bool,
22 pub deleted_at: Option<OffsetDateTime>,
23 pub created_at: OffsetDateTime,
24 pub updated_at: OffsetDateTime,
25}
26
27impl BranchDto {
28 fn from_row(row: &git_branches::Model, sha: String) -> Self {
29 Self {
30 name: row.name.clone(),
31 commit_sha: sha,
32 is_protected: row.is_protected,
33 is_deleted: row.is_deleted,
34 deleted_at: row.deleted_at,
35 created_at: row.created_at,
36 updated_at: row.updated_at,
37 }
38 }
39}
40
41#[derive(Debug, Clone, Deserialize)]
42pub struct CreateBranchCmd {
43 pub name: String,
44 #[serde(default)]
45 pub start_point: Option<String>,
46}
47
48impl GitService {
49 async fn branch_rows(
50 &self,
51 ctx: &GitContext,
52 ) -> Result<HashMap<String, git_branches::Model>, GitError> {
53 let rows = git_branches::Entity::find()
54 .filter(git_branches::Column::RepoId.eq(ctx.model.id))
55 .all(self.db_read())
56 .await?;
57 Ok(rows.into_iter().map(|r| (r.name.clone(), r)).collect())
58 }
59
60 pub async fn list_branches(
61 &self,
62 space_name: String,
63 repo_name: String,
64 ) -> Result<Vec<BranchDto>, GitError> {
65 let ctx = self.context(space_name, repo_name).await?;
66 let baby = self.baby(&ctx);
67 let branches = baby
68 .list_branches(ListBranchesOptions::default())
69 .await
70 .map_err(map_baby)?;
71 let mut rows = self.branch_rows(&ctx).await?;
72 let policy = load_branch_policy(&self.state.database, ctx.model.id).await?;
73
74 for b in &branches {
75 if !rows.contains_key(&b.name) && !b.name.is_empty() {
76 let now = OffsetDateTime::now_utc();
77 let row = git_branches::ActiveModel {
78 id: Set(uuid::Uuid::now_v7()),
79 repo_id: Set(ctx.model.id),
80 name: Set(b.name.clone()),
81 commit_sha: Set(b.sha.to_string()),
82 is_deleted: Set(false),
83 is_protected: Set(policy.is_match(&b.name)),
84 deleted_at: Set(None),
85 created_at: Set(now),
86 updated_at: Set(now),
87 };
88 let inserted = row.insert(self.db_write()).await?;
89 rows.insert(inserted.name.clone(), inserted);
90 } else if let Some(row) = rows.get(&b.name) {
91 let protected = policy.is_match(&b.name);
92 if row.is_protected != protected {
93 let am = git_branches::ActiveModel {
94 id: Unchanged(row.id),
95 is_protected: Set(protected),
96 updated_at: Set(OffsetDateTime::now_utc()),
97 ..Default::default()
98 };
99 if let Ok(updated) = am.update(self.db_write()).await {
100 rows.insert(updated.name.clone(), updated);
101 }
102 }
103 }
104 }
105
106 let mut out: Vec<BranchDto> = branches
107 .iter()
108 .filter_map(|b| {
109 rows.get(&b.name)
110 .map(|row| BranchDto::from_row(row, b.sha.to_string()))
111 })
112 .collect();
113 out.sort_by(|a, b| a.name.cmp(&b.name));
114 Ok(out)
115 }
116
117 pub async fn get_branch(
118 &self,
119 space_name: String,
120 repo_name: String,
121 name: String,
122 ) -> Result<BranchDto, GitError> {
123 validate_branch_name(&name)?;
124 let ctx = self.context(space_name, repo_name).await?;
125 let baby = self.baby(&ctx);
126 let info = baby.find_branch(&name).await.map_err(map_baby)?;
127 let rows = self.branch_rows(&ctx).await?;
128 match rows.get(&name) {
129 Some(row) => Ok(BranchDto::from_row(row, info.sha.to_string())),
130 None => Ok(BranchDto {
131 name: info.name,
132 commit_sha: info.sha.to_string(),
133 is_protected: false,
134 is_deleted: false,
135 deleted_at: None,
136 created_at: OffsetDateTime::now_utc(),
137 updated_at: OffsetDateTime::now_utc(),
138 }),
139 }
140 }
141
142 pub async fn create_branch(
143 &self,
144 space_name: String,
145 repo_name: String,
146 cmd: CreateBranchCmd,
147 ) -> Result<BranchDto, GitError> {
148 let name = validate_branch_name(&cmd.name)?;
149 if let Some(sp) = &cmd.start_point
150 && (sp.trim().is_empty() || sp.starts_with('-'))
151 {
152 return Err(GitError::InvalidArgument(format!(
153 "invalid start point `{sp}`"
154 )));
155 }
156 let ctx = self.context(space_name, repo_name).await?;
157 let baby = self.baby(&ctx);
158 baby.create_branch(&name, cmd.start_point.as_deref())
159 .await
160 .map_err(map_baby)?;
161 let info = baby.find_branch(&name).await.map_err(map_baby)?;
162
163 let rows = self.branch_rows(&ctx).await?;
164 let now = OffsetDateTime::now_utc();
165 let row = match rows.get(&name) {
166 Some(existing) => {
167 let am = git_branches::ActiveModel {
168 id: Unchanged(existing.id),
169 commit_sha: Set(info.sha.to_string()),
170 is_deleted: Set(false),
171 deleted_at: Set(None),
172 updated_at: Set(now),
173 ..Default::default()
174 };
175 am.update(self.db_write()).await?
176 }
177 None => {
178 let am = git_branches::ActiveModel {
179 id: Set(uuid::Uuid::now_v7()),
180 repo_id: Set(ctx.model.id),
181 name: Set(name.clone()),
182 commit_sha: Set(info.sha.to_string()),
183 is_deleted: Set(false),
184 is_protected: Set(false),
185 deleted_at: Set(None),
186 created_at: Set(now),
187 updated_at: Set(now),
188 };
189 am.insert(self.db_write()).await?
190 }
191 };
192 self.sync_repo_stats(&ctx).await?;
193 Ok(BranchDto::from_row(&row, info.sha.to_string()))
194 }
195
196 pub async fn rename_branch(
197 &self,
198 space_name: String,
199 repo_name: String,
200 from: String,
201 to: String,
202 ) -> Result<(), GitError> {
203 validate_branch_name(&from)?;
204 let to = validate_branch_name(&to)?;
205 let ctx = self.context(space_name, repo_name).await?;
206 let baby = self.baby(&ctx);
207 baby.rename_branch(&from, &to).await.map_err(map_baby)?;
208 let product = git_branches::Entity::find()
209 .filter(git_branches::Column::RepoId.eq(ctx.model.id))
210 .filter(git_branches::Column::Name.eq(&from))
211 .one(self.db_write())
212 .await?;
213 if let Some(row) = product {
214 let am = git_branches::ActiveModel {
215 id: Unchanged(row.id),
216 name: Set(to),
217 updated_at: Set(OffsetDateTime::now_utc()),
218 ..Default::default()
219 };
220 am.update(self.db_write()).await?;
221 }
222 self.sync_repo_stats(&ctx).await?;
223 Ok(())
224 }
225
226 pub async fn delete_branch(
227 &self,
228 space_name: String,
229 repo_name: String,
230 name: String,
231 force: bool,
232 ) -> Result<(), GitError> {
233 let name = validate_branch_name(&name)?;
234 let ctx = self.context(space_name, repo_name).await?;
235 let policy = load_branch_policy(&self.state.database, ctx.model.id).await?;
236 if policy.is_match(&name) {
237 return Err(GitError::Forbidden);
238 }
239 let baby = self.baby(&ctx);
240 let info = baby.find_branch(&name).await.map_err(map_baby)?;
241 baby.delete_branch(&name, force).await.map_err(map_baby)?;
242
243 let now = OffsetDateTime::now_utc();
244 let rows = self.branch_rows(&ctx).await?;
245 match rows.get(&name) {
246 Some(existing) => {
247 let am = git_branches::ActiveModel {
248 id: Unchanged(existing.id),
249 is_deleted: Set(true),
250 deleted_at: Set(Some(now)),
251 updated_at: Set(now),
252 ..Default::default()
253 };
254 am.update(self.db_write()).await?;
255 }
256 None => {
257 let am = git_branches::ActiveModel {
258 id: Set(uuid::Uuid::now_v7()),
259 repo_id: Set(ctx.model.id),
260 name: Set(name.clone()),
261 commit_sha: Set(info.sha.to_string()),
262 is_deleted: Set(true),
263 is_protected: Set(false),
264 deleted_at: Set(Some(now)),
265 created_at: Set(now),
266 updated_at: Set(now),
267 };
268 am.insert(self.db_write()).await?;
269 }
270 }
271 self.sync_repo_stats(&ctx).await?;
272 Ok(())
273 }
274
275 pub async fn set_default_branch(
276 &self,
277 space_name: String,
278 repo_name: String,
279 name: String,
280 ) -> Result<(), GitError> {
281 let name = validate_branch_name(&name)?;
282 let ctx = self.context(space_name, repo_name).await?;
283 let baby = self.baby(&ctx);
284 baby.set_default_branch(&name).await.map_err(map_baby)?;
285 let am = repos::ActiveModel {
286 id: Unchanged(ctx.model.id),
287 default_branch: Set(name),
288 updated_at: Set(OffsetDateTime::now_utc()),
289 ..Default::default()
290 };
291 am.update(self.db_write()).await?;
292 Ok(())
293 }
294}
Added lib/git/src/service/cache.rs View lib/git/src/service/cache.rs
@@ -0,0 +1,82 @@
1use std::time::Duration;
2
3use cache::CacheClient;
4use serde::{Serialize, de::DeserializeOwned};
5
6use crate::share::errors::GitError;
7
8pub const REV_TTL: Duration = Duration::from_secs(60);
9
10fn sanitize_key(k: &str) -> String {
11 let mut out = String::with_capacity(k.len());
12 for c in k.chars() {
13 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':') {
14 out.push(c);
15 } else {
16 out.push('_');
17 }
18 }
19 out
20}
21
22fn oid_key(kind: &str, oid: &str) -> String {
23 sanitize_key(&format!("object.{kind}.{oid}"))
24}
25
26fn rev_key(module: &str, func: &str, key_part: &str, tip: &str) -> String {
27 sanitize_key(&format!("{module}.{func}.{key_part}:{tip}"))
28}
29
30pub async fn cache_oid_get<V>(
31 cache: &CacheClient,
32 kind: &str,
33 oid: &str,
34) -> Result<Option<V>, GitError>
35where
36 V: DeserializeOwned,
37{
38 Ok(cache.get(&oid_key(kind, oid)).await?)
39}
40
41pub async fn cache_oid_put<V>(
42 cache: &CacheClient,
43 kind: &str,
44 oid: &str,
45 v: &V,
46) -> Result<(), GitError>
47where
48 V: Serialize,
49{
50 cache.set(&oid_key(kind, oid), v, None).await?;
51 Ok(())
52}
53
54pub async fn cache_rev_get<V>(
55 cache: &CacheClient,
56 module: &str,
57 func: &str,
58 key_part: &str,
59 tip: &str,
60) -> Result<Option<V>, GitError>
61where
62 V: DeserializeOwned,
63{
64 Ok(cache.get(&rev_key(module, func, key_part, tip)).await?)
65}
66
67pub async fn cache_rev_put<V>(
68 cache: &CacheClient,
69 module: &str,
70 func: &str,
71 key_part: &str,
72 tip: &str,
73 v: &V,
74) -> Result<(), GitError>
75where
76 V: Serialize,
77{
78 cache
79 .set(&rev_key(module, func, key_part, tip), v, Some(REV_TTL))
80 .await?;
81 Ok(())
82}
Added lib/git/src/service/commits.rs View lib/git/src/service/commits.rs
@@ -0,0 +1,331 @@
1use std::path::PathBuf;
2
3use gitbaby::commit::types::{CommitInfo, CommitStats, ListCommitsOptions, TreeEntry};
4use models::git::git_commit_status;
5use models::git::git_commit_status::CommitStatusState;
6use models::git::git_commits;
7use models::users::user_emails;
8use sea_orm::{
9 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
10 Unchanged,
11};
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14use uuid::Uuid;
15
16use crate::context::facade::GitContext;
17use crate::service::GitService;
18use crate::share::errors::{GitError, map_baby};
19
20fn default_revision() -> String {
21 "HEAD".to_string()
22}
23
24#[derive(Debug, Clone, Deserialize)]
25pub struct ListCommitsCmd {
26 #[serde(default = "default_revision")]
27 pub revision: String,
28 #[serde(default)]
29 pub path: Option<PathBuf>,
30 #[serde(default)]
31 pub max_count: Option<u32>,
32 #[serde(default)]
33 pub since: Option<String>,
34 #[serde(default)]
35 pub until: Option<String>,
36 #[serde(default)]
37 pub first_parent: bool,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum CommitStatusDtoState {
43 Pending,
44 Success,
45 Failure,
46 Error,
47}
48
49impl From<CommitStatusDtoState> for CommitStatusState {
50 fn from(s: CommitStatusDtoState) -> Self {
51 match s {
52 CommitStatusDtoState::Pending => CommitStatusState::Pending,
53 CommitStatusDtoState::Success => CommitStatusState::Success,
54 CommitStatusDtoState::Failure => CommitStatusState::Failure,
55 CommitStatusDtoState::Error => CommitStatusState::Error,
56 }
57 }
58}
59
60impl From<CommitStatusState> for CommitStatusDtoState {
61 fn from(s: CommitStatusState) -> Self {
62 match s {
63 CommitStatusState::Pending => CommitStatusDtoState::Pending,
64 CommitStatusState::Success => CommitStatusDtoState::Success,
65 CommitStatusState::Failure => CommitStatusDtoState::Failure,
66 CommitStatusState::Error => CommitStatusDtoState::Error,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Deserialize)]
72pub struct SetCommitStatusCmd {
73 pub sha: String,
74 pub state: CommitStatusDtoState,
75 pub context: String,
76 #[serde(default)]
77 pub description: Option<String>,
78 #[serde(default)]
79 pub target_url: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize)]
83pub struct CommitStatusDto {
84 pub state: CommitStatusDtoState,
85 pub context: String,
86 pub description: Option<String>,
87 pub target_url: Option<String>,
88 pub creator_id: Uuid,
89 pub created_at: OffsetDateTime,
90 pub updated_at: Option<OffsetDateTime>,
91}
92
93impl From<git_commit_status::Model> for CommitStatusDto {
94 fn from(m: git_commit_status::Model) -> Self {
95 Self {
96 state: m.state.into(),
97 context: m.context,
98 description: m.description,
99 target_url: m.target_url,
100 creator_id: m.creator_id,
101 created_at: m.created_at,
102 updated_at: m.updated_at,
103 }
104 }
105}
106
107async fn uid_for_email(db: &DatabaseConnection, email: &str) -> Result<Option<Uuid>, GitError> {
108 let row = user_emails::Entity::find()
109 .filter(user_emails::Column::LowerEmail.eq(email.to_lowercase()))
110 .one(db)
111 .await?;
112 Ok(row.map(|r| r.user_id))
113}
114
115impl GitService {
116 pub async fn get_commit(
117 &self,
118 space_name: String,
119 repo_name: String,
120 revision: String,
121 ) -> Result<CommitInfo, GitError> {
122 let ctx = self.context(space_name, repo_name).await?;
123 let baby = self.baby(&ctx);
124 baby.get_commit(&revision).await.map_err(map_baby)
125 }
126
127 pub async fn list_commits(
128 &self,
129 space_name: String,
130 repo_name: String,
131 cmd: ListCommitsCmd,
132 ) -> Result<Vec<CommitInfo>, GitError> {
133 let ctx = self.context(space_name, repo_name).await?;
134 let baby = self.baby(&ctx);
135 let opts = ListCommitsOptions {
136 revision: cmd.revision,
137 path: cmd.path,
138 max_count: cmd.max_count,
139 since: cmd.since,
140 until: cmd.until,
141 first_parent: cmd.first_parent,
142 };
143 baby.list_commits(opts).await.map_err(map_baby)
144 }
145
146 pub async fn commit_stats(
147 &self,
148 space_name: String,
149 repo_name: String,
150 revision: String,
151 path: Option<PathBuf>,
152 ) -> Result<CommitStats, GitError> {
153 let ctx = self.context(space_name, repo_name).await?;
154 let baby = self.baby(&ctx);
155 baby.commit_stats(&revision, path.as_deref())
156 .await
157 .map_err(map_baby)
158 }
159
160 pub async fn get_tree_entries(
161 &self,
162 space_name: String,
163 repo_name: String,
164 revision: String,
165 path: PathBuf,
166 ) -> Result<Vec<TreeEntry>, GitError> {
167 let ctx = self.context(space_name, repo_name).await?;
168 let baby = self.baby(&ctx);
169 baby.get_tree_entries(&revision, &path)
170 .await
171 .map_err(map_baby)
172 }
173
174 pub async fn rev_parse(
175 &self,
176 space_name: String,
177 repo_name: String,
178 revision: String,
179 ) -> Result<String, GitError> {
180 let ctx = self.context(space_name, repo_name).await?;
181 let baby = self.baby(&ctx);
182 baby.rev_parse(&revision)
183 .await
184 .map(|oid| oid.to_string())
185 .map_err(map_baby)
186 }
187
188 pub async fn is_ancestor(
189 &self,
190 space_name: String,
191 repo_name: String,
192 ancestor: String,
193 descendant: String,
194 ) -> Result<bool, GitError> {
195 let ctx = self.context(space_name, repo_name).await?;
196 let baby = self.baby(&ctx);
197 baby.is_ancestor(&ancestor, &descendant)
198 .await
199 .map_err(map_baby)
200 }
201
202 /// Upsert the DB mirror of a commit. `author_id` is resolved
203 /// best-effort from the commit author email.
204 pub async fn sync_commit(
205 &self,
206 ctx: &GitContext,
207 revision: &str,
208 ) -> Result<git_commits::Model, GitError> {
209 let baby = self.baby(ctx);
210 let info = baby.get_commit(revision).await.map_err(map_baby)?;
211 let sha = info.id.to_string();
212 let existing = git_commits::Entity::find_by_id((ctx.model.id, sha.clone()))
213 .one(self.db_read())
214 .await?;
215 let author_id = uid_for_email(self.db_read(), &info.author.email).await?;
216 let now = OffsetDateTime::now_utc();
217 let row = match existing {
218 Some(cur) => {
219 let am = git_commits::ActiveModel {
220 repo_id: Unchanged(cur.repo_id),
221 sha: Unchanged(cur.sha),
222 message: Set(info.message.clone()),
223 author_id: Set(author_id),
224 author_name: Set(info.author.name.clone()),
225 author_email: Set(info.author.email.clone()),
226 authored_at: Set(info.author.time),
227 committer_name: Set(info.committer.name.clone()),
228 committer_email: Set(info.committer.email.clone()),
229 committed_at: Set(info.committer.time),
230 ..Default::default()
231 };
232 am.update(self.db_write()).await?
233 }
234 None => {
235 let am = git_commits::ActiveModel {
236 repo_id: Set(ctx.model.id),
237 sha: Set(sha.clone()),
238 message: Set(info.message.clone()),
239 author_id: Set(author_id),
240 author_name: Set(info.author.name.clone()),
241 author_email: Set(info.author.email.clone()),
242 authored_at: Set(info.author.time),
243 committer_name: Set(info.committer.name.clone()),
244 committer_email: Set(info.committer.email.clone()),
245 committed_at: Set(info.committer.time),
246 created_at: Set(now),
247 };
248 am.insert(self.db_write()).await?
249 }
250 };
251 Ok(row)
252 }
253
254 pub async fn set_commit_status(
255 &self,
256 space_name: String,
257 repo_name: String,
258 creator: Uuid,
259 cmd: SetCommitStatusCmd,
260 ) -> Result<CommitStatusDto, GitError> {
261 if cmd.sha.trim().is_empty() {
262 return Err(GitError::InvalidArgument("empty sha".to_string()));
263 }
264 if cmd.context.trim().is_empty() || cmd.context.chars().count() > 255 {
265 return Err(GitError::InvalidArgument("invalid context".to_string()));
266 }
267 let ctx = self.context(space_name, repo_name).await?;
268 self.baby(&ctx)
269 .rev_parse(cmd.sha.trim())
270 .await
271 .map_err(map_baby)?;
272 self.sync_commit(&ctx, cmd.sha.trim()).await?;
273
274 let existing = git_commit_status::Entity::find()
275 .filter(git_commit_status::Column::RepoId.eq(ctx.model.id))
276 .filter(git_commit_status::Column::Sha.eq(cmd.sha.trim()))
277 .filter(git_commit_status::Column::Context.eq(cmd.context.trim()))
278 .one(self.db_write())
279 .await?;
280
281 let now = OffsetDateTime::now_utc();
282 let model = match existing {
283 Some(cur) => {
284 let am = git_commit_status::ActiveModel {
285 id: Unchanged(cur.id),
286 state: Set(cmd.state.into()),
287 description: Set(cmd.description.clone()),
288 target_url: Set(cmd.target_url.clone()),
289 updated_at: Set(Some(now)),
290 ..Default::default()
291 };
292 am.update(self.db_write()).await?
293 }
294 None => {
295 let am = git_commit_status::ActiveModel {
296 id: Set(Uuid::now_v7()),
297 repo_id: Set(ctx.model.id),
298 sha: Set(cmd.sha.trim().to_string()),
299 state: Set(cmd.state.into()),
300 context: Set(cmd.context.trim().to_string()),
301 description: Set(cmd.description.clone()),
302 target_url: Set(cmd.target_url.clone()),
303 creator_id: Set(creator),
304 created_at: Set(now),
305 updated_at: Set(None),
306 };
307 am.insert(self.db_write()).await?
308 }
309 };
310 Ok(CommitStatusDto::from(model))
311 }
312
313 pub async fn list_commit_statuses(
314 &self,
315 space_name: String,
316 repo_name: String,
317 sha: String,
318 ) -> Result<Vec<CommitStatusDto>, GitError> {
319 if sha.trim().is_empty() {
320 return Err(GitError::InvalidArgument("empty sha".to_string()));
321 }
322 let ctx = self.context(space_name, repo_name).await?;
323 let rows = git_commit_status::Entity::find()
324 .filter(git_commit_status::Column::RepoId.eq(ctx.model.id))
325 .filter(git_commit_status::Column::Sha.eq(sha.trim()))
326 .order_by_asc(git_commit_status::Column::CreatedAt)
327 .all(self.db_read())
328 .await?;
329 Ok(rows.into_iter().map(CommitStatusDto::from).collect())
330 }
331}
Added lib/git/src/service/conflicts.rs View lib/git/src/service/conflicts.rs
@@ -0,0 +1,183 @@
1use gitbaby::conflict::types::{ConflictFile, ListConflictOptions};
2use serde::Serialize;
3
4use crate::service::GitService;
5use crate::share::errors::{GitError, map_baby};
6
7#[derive(Debug, Clone, Serialize)]
8pub struct MergeStatusDto {
9 pub mergeable: bool,
10 pub conflicts: Vec<String>,
11 pub fast_forwardable: bool,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct ConflictDetailDto {
16 pub path: String,
17 pub content: Option<String>,
18}
19
20impl GitService {
21 async fn resolve_pair(
22 &self,
23 ctx: &crate::context::facade::GitContext,
24 ours: &str,
25 theirs: &str,
26 ) -> Result<(String, String), GitError> {
27 let baby = self.baby(ctx);
28 let our_oid = baby.rev_parse(ours).await.map_err(map_baby)?;
29 let their_oid = baby.rev_parse(theirs).await.map_err(map_baby)?;
30 Ok((our_oid.to_string(), their_oid.to_string()))
31 }
32
33 pub async fn conflict_check(
34 &self,
35 space_name: String,
36 repo_name: String,
37 base: String,
38 head: String,
39 ) -> Result<MergeStatusDto, GitError> {
40 let ctx = self.context(space_name, repo_name).await?;
41 let baby = self.baby(&ctx);
42 let fast_forwardable = baby.is_ancestor(&base, &head).await.map_err(map_baby)?;
43
44 let (our_oid, their_oid) = self.resolve_pair(&ctx, &base, &head).await?;
45 let files = baby
46 .list_conflict_files(
47 &our_oid,
48 &their_oid,
49 &ListConflictOptions {
50 allow_tree_conflicts: false,
51 skip_content: true,
52 },
53 )
54 .await
55 .map_err(map_baby)?;
56 let conflicts: Vec<String> = files
57 .iter()
58 .filter_map(|f| {
59 f.header
60 .our_path
61 .clone()
62 .map(|p| p.to_string_lossy().into_owned())
63 .or_else(|| {
64 f.header
65 .their_path
66 .clone()
67 .map(|p| p.to_string_lossy().into_owned())
68 })
69 })
70 .collect();
71 Ok(MergeStatusDto {
72 mergeable: conflicts.is_empty(),
73 fast_forwardable,
74 conflicts,
75 })
76 }
77
78 pub async fn conflict_conflicted_files(
79 &self,
80 space_name: String,
81 repo_name: String,
82 base: String,
83 head: String,
84 ) -> Result<Vec<String>, GitError> {
85 let ctx = self.context(space_name, repo_name).await?;
86 let baby = self.baby(&ctx);
87 let (our_oid, their_oid) = self.resolve_pair(&ctx, &base, &head).await?;
88 let files = baby
89 .list_conflict_files(
90 &our_oid,
91 &their_oid,
92 &ListConflictOptions {
93 allow_tree_conflicts: false,
94 skip_content: false,
95 },
96 )
97 .await
98 .map_err(map_baby)?;
99 Ok(conflict_paths(&files))
100 }
101 pub async fn conflict_detail(
102 &self,
103 space_name: String,
104 repo_name: String,
105 base: String,
106 head: String,
107 path: String,
108 ) -> Result<ConflictDetailDto, GitError> {
109 let ctx = self.context(space_name, repo_name).await?;
110 let baby = self.baby(&ctx);
111 let (our_oid, their_oid) = self.resolve_pair(&ctx, &base, &head).await?;
112 let files = baby
113 .list_conflict_files(
114 &our_oid,
115 &their_oid,
116 &ListConflictOptions {
117 allow_tree_conflicts: false,
118 skip_content: false,
119 },
120 )
121 .await
122 .map_err(map_baby)?;
123 let wanted = std::path::Path::new(&path);
124 let file = files.into_iter().find(|f| {
125 f.header.our_path.as_deref() == Some(wanted)
126 || f.header.their_path.as_deref() == Some(wanted)
127 });
128 match file {
129 Some(f) => Ok(ConflictDetailDto {
130 path,
131 content: f.content,
132 }),
133 None => Err(GitError::NotFound),
134 }
135 }
136
137 pub async fn conflict_check_threeway(
138 &self,
139 space_name: String,
140 repo_name: String,
141 ours: String,
142 theirs: String,
143 ancestor: String,
144 ) -> Result<MergeStatusDto, GitError> {
145 let _ = &ancestor;
146 let ctx = self.context(space_name, repo_name).await?;
147 let baby = self.baby(&ctx);
148 let (our_oid, their_oid) = self.resolve_pair(&ctx, &ours, &theirs).await?;
149 let merged = baby
150 .merge_tree(&our_oid, &their_oid, Some(&ancestor), false)
151 .await
152 .map_err(map_baby)?;
153 let fast_forwardable = baby.is_ancestor(&ours, &theirs).await.map_err(map_baby)?;
154 let conflicts: Vec<String> = merged
155 .conflict_files
156 .iter()
157 .map(|p| p.to_string_lossy().into_owned())
158 .collect();
159 Ok(MergeStatusDto {
160 mergeable: !merged.has_conflicts,
161 conflicts,
162 fast_forwardable,
163 })
164 }
165}
166
167fn conflict_paths(files: &[ConflictFile]) -> Vec<String> {
168 files
169 .iter()
170 .filter_map(|f| {
171 f.header
172 .our_path
173 .clone()
174 .map(|p| p.to_string_lossy().into_owned())
175 .or_else(|| {
176 f.header
177 .their_path
178 .clone()
179 .map(|p| p.to_string_lossy().into_owned())
180 })
181 })
182 .collect()
183}
Added lib/git/src/service/content.rs View lib/git/src/service/content.rs
@@ -0,0 +1,246 @@
1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use gitbaby::repo::RepositoryFacade;
5use gix::actor::Signature;
6use gix::bstr::BString;
7use gix::date::Time;
8use gix::objs::Commit;
9use gix::objs::Write;
10use gix::{ObjectId, objs::Kind};
11use serde::{Deserialize, Serialize};
12use time::OffsetDateTime;
13
14use crate::service::GitService;
15use crate::share::errors::{GitError, map_baby};
16use crate::share::identity::load_identity;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SignatureDto {
20 pub name: String,
21 pub email: String,
22 pub when: OffsetDateTime,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum ObjectKind {
28 Merge,
29 Squash,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct FileEntryDto {
34 pub path: String,
35 pub content: Vec<u8>,
36 pub executable: bool,
37}
38
39#[derive(Debug, Clone, Serialize)]
40pub struct WriteObjectResultDto {
41 pub id: String,
42}
43
44fn gix_signature(sig: &SignatureDto, offset_seconds: i32) -> Signature {
45 Signature {
46 name: BString::from(sig.name.clone()),
47 email: BString::from(sig.email.clone()),
48 time: Time {
49 seconds: sig.when.unix_timestamp(),
50 offset: offset_seconds,
51 },
52 }
53}
54
55impl GitService {
56 pub async fn caller_signature(
57 &self,
58 caller: uuid::Uuid,
59 when: OffsetDateTime,
60 ) -> Result<SignatureDto, GitError> {
61 let ident = load_identity(&self.state.database, caller).await?;
62 Ok(SignatureDto {
63 name: ident.name,
64 email: ident.email,
65 when,
66 })
67 }
68
69 /// Write a blob object (no ref update).
70 pub async fn write_blob(
71 &self,
72 space_name: String,
73 repo_name: String,
74 data: Vec<u8>,
75 ) -> Result<WriteObjectResultDto, GitError> {
76 if data.len() as u64 > self.state.config.git.max_blob_size {
77 return Err(GitError::InvalidArgument(
78 "blob exceeds max size".to_string(),
79 ));
80 }
81 let ctx = self.context(space_name, repo_name).await?;
82 let repo = ctx.gix_repo().await.map_err(GitError::from)?;
83 let id = repo
84 .write_blob(data)
85 .map_err(|e| GitError::Internal(format!("write blob failed: {e}")))?;
86 Ok(WriteObjectResultDto { id: id.to_string() })
87 }
88
89 /// Build a tree object from flat file entries (recursive dirs created
90 /// implicitly). Returns the new tree id without touching any ref.
91 pub async fn write_tree_from_files(
92 &self,
93 space_name: String,
94 repo_name: String,
95 files: Vec<FileEntryDto>,
96 ) -> Result<WriteObjectResultDto, GitError> {
97 let ctx = self.context(space_name, repo_name).await?;
98 let repo = ctx.gix_repo().await.map_err(GitError::from)?;
99
100 let mut blobs: BTreeMap<String, ObjectId> = BTreeMap::new();
101 for f in &files {
102 let path = clean_rel_path(&f.path)?;
103 let id = repo
104 .write_blob(&f.content)
105 .map_err(|e| GitError::Internal(format!("write blob `{path}` failed: {e}")))?;
106 blobs.insert(path, id.into());
107 }
108 let tree_oid = build_tree_recursive(&repo, "", &blobs).await?;
109 Ok(WriteObjectResultDto {
110 id: tree_oid.to_string(),
111 })
112 }
113
114 /// Write a commit object (merge = arbitrary parents, squash = one
115 /// parent). Does not move any ref or HEAD.
116 #[allow(clippy::too_many_arguments)]
117 pub async fn commit_object(
118 &self,
119 space_name: String,
120 repo_name: String,
121 kind: ObjectKind,
122 parents: Vec<String>,
123 tree: String,
124 message: String,
125 author: SignatureDto,
126 committer: SignatureDto,
127 ) -> Result<WriteObjectResultDto, GitError> {
128 if parents.is_empty() {
129 return Err(GitError::InvalidArgument(
130 "commit requires at least one parent".to_string(),
131 ));
132 }
133 let ctx = self.context(space_name, repo_name).await?;
134 let baby = self.baby(&ctx);
135 let parent_oids: Vec<gix::ObjectId> = match kind {
136 ObjectKind::Merge => {
137 let mut oids = Vec::with_capacity(parents.len());
138 for p in &parents {
139 oids.push(baby.rev_parse(p).await.map_err(map_baby)?);
140 }
141 oids
142 }
143 ObjectKind::Squash => {
144 vec![baby.rev_parse(&parents[0]).await.map_err(map_baby)?]
145 }
146 };
147 let tree_id = gix::ObjectId::from_hex(tree.trim().as_bytes())
148 .map_err(|_| GitError::InvalidArgument("invalid tree id".to_string()))?;
149 let repo = ctx.gix_repo().await.map_err(GitError::from)?;
150 let commit = Commit {
151 tree: tree_id,
152 parents: parent_oids.into(),
153 author: gix_signature(&author, 0),
154 committer: gix_signature(&committer, 0),
155 encoding: None,
156 message: BString::from(message.clone()),
157 extra_headers: Vec::new(),
158 };
159 let id = repo
160 .write_object(&commit)
161 .map_err(|e| GitError::Internal(format!("write commit failed: {e}")))?;
162 Ok(WriteObjectResultDto { id: id.to_string() })
163 }
164}
165
166fn clean_rel_path(path: &str) -> Result<String, GitError> {
167 let p = PathBuf::from(path);
168 if p.is_absolute()
169 || p.components()
170 .any(|c| matches!(c, std::path::Component::ParentDir))
171 {
172 return Err(GitError::InvalidArgument(format!(
173 "invalid relative path `{path}`"
174 )));
175 }
176 Ok(p.to_string_lossy().replace('\\', "/"))
177}
178
179fn tree_entry_bytes(mode_octal: u32, name: &str, oid: ObjectId) -> Vec<u8> {
180 let mut buf = Vec::with_capacity(64 + name.len());
181 use std::io::Write as _;
182 write!(&mut buf, "{:o} ", mode_octal).unwrap();
183 buf.extend_from_slice(name.as_bytes());
184 buf.push(0);
185 buf.extend_from_slice(oid.as_bytes());
186 buf
187}
188
189fn build_tree_recursive_sync(
190 prefix: &str,
191 blobs: &BTreeMap<String, ObjectId>,
192 write_tree: &mut dyn FnMut(&[u8]) -> Result<ObjectId, GitError>,
193) -> Result<ObjectId, GitError> {
194 // Group blobs by their first path segment relative to `prefix`.
195 let mut direct: BTreeMap<String, ObjectId> = BTreeMap::new();
196 let mut dir_blobs: BTreeMap<String, BTreeMap<String, ObjectId>> = BTreeMap::new();
197 for (path, oid) in blobs {
198 if !path.starts_with(prefix) {
199 continue;
200 }
201 let rest = &path[prefix.len()..];
202 if rest.is_empty() {
203 continue;
204 }
205 let head = rest.split('/').next().unwrap_or_default();
206 if rest == head {
207 direct.insert(head.to_string(), *oid);
208 } else {
209 let _dir = format!("{prefix}{head}/");
210 let entry = dir_blobs.entry(head.to_string()).or_default();
211 entry.insert(path.clone(), *oid);
212 }
213 }
214 let mut buf: Vec<u8> = Vec::new();
215 for (name, oid) in &direct {
216 buf.extend_from_slice(&tree_entry_bytes(0o100644, name, *oid));
217 }
218 let mut sub_meta: Vec<(String, ObjectId)> = Vec::new();
219 for (dir_name, sub_blobs) in &dir_blobs {
220 let sub_prefix = format!("{prefix}{dir_name}/");
221 let sub_oid = build_tree_recursive_sync(&sub_prefix, sub_blobs, write_tree)?;
222 sub_meta.push((dir_name.clone(), sub_oid));
223 }
224 sub_meta.sort_by(|a, b| a.0.cmp(&b.0));
225 for (name, oid) in &sub_meta {
226 buf.extend_from_slice(&tree_entry_bytes(0o040000, name, *oid));
227 }
228 let mut object = format!("tree {}\0", buf.len()).into_bytes();
229 object.extend_from_slice(&buf);
230 write_tree(&object)
231}
232
233async fn build_tree_recursive(
234 repo: &gix::Repository,
235 prefix: &str,
236 blobs: &BTreeMap<String, ObjectId>,
237) -> Result<ObjectId, GitError> {
238 let blobs = blobs.clone();
239 let objects = repo.objects.clone();
240 let mut write_tree = move |data: &[u8]| -> Result<ObjectId, GitError> {
241 objects
242 .write_buf(Kind::Tree, data)
243 .map_err(|e| GitError::Internal(format!("write tree failed: {e}")))
244 };
245 build_tree_recursive_sync(prefix, &blobs, &mut write_tree)
246}
Added lib/git/src/service/context.rs View lib/git/src/service/context.rs
@@ -0,0 +1,70 @@
1use std::path::PathBuf;
2use std::sync::Arc;
3
4use cache::CacheClient;
5use models::dir::dirs;
6use models::repo::repos;
7use models::space::spaces;
8use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
9
10use crate::context::facade::GitContext;
11use crate::service::GitService;
12use crate::share::errors::GitError;
13
14impl GitService {
15 pub async fn context(
16 &self,
17 space_name: String,
18 repo_name: String,
19 ) -> Result<GitContext, GitError> {
20 let space_lower = space_name.trim().to_lowercase();
21 if space_lower.is_empty() {
22 return Err(GitError::NotFound);
23 }
24 let space = spaces::Entity::find()
25 .filter(spaces::Column::LowerName.eq(&space_lower))
26 .filter(spaces::Column::IsActive.eq(true))
27 .one(self.db_read())
28 .await?
29 .ok_or(GitError::NotFound)?;
30
31 let repo_lower = repo_name.trim().to_lowercase();
32 if repo_lower.is_empty() {
33 return Err(GitError::NotFound);
34 }
35 let repo = repos::Entity::find()
36 .filter(repos::Column::LowerName.eq(&repo_lower))
37 .one(self.db_read())
38 .await?
39 .ok_or(GitError::NotFound)?;
40
41 let mut dir_id = repo.dir_id;
42 let owned_by_space = loop {
43 let dir = dirs::Entity::find_by_id(dir_id)
44 .one(self.db_read())
45 .await?
46 .ok_or(GitError::NotFound)?;
47 if dir.space_id == Some(space.id) {
48 break true;
49 }
50 match dir.parent_dir_id {
51 Some(parent) => dir_id = parent,
52 None => break false,
53 }
54 };
55 if !owned_by_space {
56 return Err(GitError::NotFound);
57 }
58
59 let cache = Arc::new(CacheClient::new(&self.state.config.cache).await?);
60 let git = &self.state.config.git;
61
62 Ok(GitContext {
63 model: repo,
64 cache,
65 database: self.state.database.clone(),
66 repo_root: PathBuf::from(&git.repo_root),
67 objects_root: PathBuf::from(&git.objects_root),
68 })
69 }
70}
Added lib/git/src/service/diffs.rs View lib/git/src/service/diffs.rs
@@ -0,0 +1,215 @@
1use gitbaby::diff::types::{
2 ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, NumStat,
3 RangeDiffSpec, ShortStat,
4};
5use gix::ObjectId;
6use serde::{Deserialize, Serialize};
7
8use crate::service::GitService;
9use crate::share::errors::{GitError, map_baby};
10
11pub fn parse_oid(hex: &str) -> Result<ObjectId, GitError> {
12 ObjectId::from_hex(hex.trim().as_bytes())
13 .map_err(|_| GitError::InvalidArgument(format!("invalid object id `{hex}`")))
14}
15
16#[derive(Debug, Clone, Deserialize, Default)]
17pub struct DiffOptionsDto {
18 #[serde(default)]
19 pub paths: Option<Vec<std::path::PathBuf>>,
20 #[serde(default)]
21 pub detect_renames: Option<u8>,
22}
23
24impl From<DiffOptionsDto> for DiffOptions {
25 fn from(d: DiffOptionsDto) -> Self {
26 Self {
27 paths: d.paths,
28 detect_renames: d.detect_renames,
29 ..Default::default()
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize)]
35pub struct DiffTreeRecordDto {
36 pub status: String,
37 pub score: u8,
38 pub head_path: Option<String>,
39 pub base_path: Option<String>,
40 pub head_mode: Option<u32>,
41 pub base_mode: Option<u32>,
42 pub head_blob_id: Option<String>,
43 pub base_blob_id: Option<String>,
44}
45
46impl GitService {
47 async fn diff_request(
48 &self,
49 ctx: &crate::context::facade::GitContext,
50 base: &str,
51 head: &str,
52 ) -> Result<DiffRequest, GitError> {
53 let baby = self.baby(ctx);
54 let left = baby.rev_parse(base).await.map_err(map_baby)?;
55 let right = baby.rev_parse(head).await.map_err(map_baby)?;
56 Ok(DiffRequest { left, right })
57 }
58
59 pub async fn diff_raw(
60 &self,
61 space_name: String,
62 repo_name: String,
63 base: String,
64 head: String,
65 ) -> Result<String, GitError> {
66 let ctx = self.context(space_name, repo_name).await?;
67 let req = self.diff_request(&ctx, &base, &head).await?;
68 self.baby(&ctx).raw_diff(&req, None).await.map_err(map_baby)
69 }
70
71 pub async fn diff_files_changed_between(
72 &self,
73 space_name: String,
74 repo_name: String,
75 base: String,
76 head: String,
77 ) -> Result<Vec<String>, GitError> {
78 let ctx = self.context(space_name, repo_name).await?;
79 let baby = self.baby(&ctx);
80 let left = baby.rev_parse(&base).await.map_err(map_baby)?;
81 let right = baby.rev_parse(&head).await.map_err(map_baby)?;
82 let paths = baby
83 .find_changed_paths(
84 vec![ChangedPathRequest::TreePair(left, right)],
85 None,
86 None,
87 false,
88 )
89 .await
90 .map_err(map_baby)?;
91 Ok(paths
92 .into_iter()
93 .map(|p| p.new_path.to_string_lossy().into_owned())
94 .collect())
95 }
96
97 pub async fn diff_short_stat(
98 &self,
99 space_name: String,
100 repo_name: String,
101 base: String,
102 head: String,
103 ) -> Result<ShortStat, GitError> {
104 let ctx = self.context(space_name, repo_name).await?;
105 let req = self.diff_request(&ctx, &base, &head).await?;
106 self.baby(&ctx)
107 .shortstat(&req, &DiffOptions::default())
108 .await
109 .map_err(map_baby)
110 }
111
112 pub async fn diff_stats(
113 &self,
114 space_name: String,
115 repo_name: String,
116 base: String,
117 head: String,
118 opts: DiffOptionsDto,
119 ) -> Result<Vec<NumStat>, GitError> {
120 let ctx = self.context(space_name, repo_name).await?;
121 let req = self.diff_request(&ctx, &base, &head).await?;
122 self.baby(&ctx)
123 .diff_stats(&req, &opts.into())
124 .await
125 .map_err(map_baby)
126 }
127
128 pub async fn diff_files(
129 &self,
130 space_name: String,
131 repo_name: String,
132 base: String,
133 head: String,
134 opts: DiffOptionsDto,
135 ) -> Result<Vec<ChangedPathDiff>, GitError> {
136 let ctx = self.context(space_name, repo_name).await?;
137 let req = self.diff_request(&ctx, &base, &head).await?;
138 self.baby(&ctx)
139 .commit_diff(&req, &opts.into(), None)
140 .await
141 .map_err(map_baby)
142 }
143
144 pub async fn diff_for_commit(
145 &self,
146 space_name: String,
147 repo_name: String,
148 commit: String,
149 ) -> Result<Vec<ChangedPath>, GitError> {
150 let ctx = self.context(space_name, repo_name).await?;
151 let oid = parse_oid(&commit)?;
152 self.baby(&ctx)
153 .find_changed_paths(vec![ChangedPathRequest::Commit(oid)], None, None, false)
154 .await
155 .map_err(map_baby)
156 }
157
158 pub async fn diff_tree(
159 &self,
160 space_name: String,
161 repo_name: String,
162 base: String,
163 head: String,
164 use_merge_base: bool,
165 ) -> Result<Vec<DiffTreeRecordDto>, GitError> {
166 let ctx = self.context(space_name, repo_name).await?;
167 let baby = self.baby(&ctx);
168 let left = if use_merge_base {
169 let mb = baby.merge_base(&base, &head).await.map_err(map_baby)?;
170 mb.to_string()
171 } else {
172 baby.rev_parse(&base).await.map_err(map_baby)?.to_string()
173 };
174 let right = baby.rev_parse(&head).await.map_err(map_baby)?;
175 let rows = baby
176 .diff_trees(
177 &left,
178 &right.to_string(),
179 std::path::Path::new(""),
180 gitbaby::tree::types::DiffTreesOptions {
181 recurse: true,
182 detect_renames: true,
183 ..Default::default()
184 },
185 )
186 .await
187 .map_err(map_baby)?;
188 Ok(rows
189 .into_iter()
190 .map(|d| DiffTreeRecordDto {
191 status: format!("{:?}", d.status).to_lowercase(),
192 score: 0,
193 head_path: d.new_path.map(|p| p.to_string_lossy().into_owned()),
194 base_path: d.old_path.map(|p| p.to_string_lossy().into_owned()),
195 head_mode: d.new_mode.map(|m| m as u32),
196 base_mode: d.old_mode.map(|m| m as u32),
197 head_blob_id: d.new_oid.map(|o| o.to_string()),
198 base_blob_id: d.old_oid.map(|o| o.to_string()),
199 })
200 .collect())
201 }
202
203 pub async fn range_diff(
204 &self,
205 space_name: String,
206 repo_name: String,
207 spec: RangeDiffSpec,
208 ) -> Result<String, GitError> {
209 let ctx = self.context(space_name, repo_name).await?;
210 self.baby(&ctx)
211 .range_diff(&spec, &DiffOptions::default(), None)
212 .await
213 .map_err(map_baby)
214 }
215}
Added lib/git/src/service/hooks.rs View lib/git/src/service/hooks.rs
@@ -0,0 +1,98 @@
1use serde::{Deserialize, Serialize};
2
3use crate::service::GitService;
4use crate::share::errors::GitError;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "kebab-case")]
8pub enum HookEventKind {
9 PreReceive,
10 PostReceive,
11 Update,
12 Push,
13 Tag,
14 MergeRequest,
15}
16
17#[derive(Debug, Clone, Serialize)]
18pub struct HookRefDto {
19 pub name: String,
20 pub old_oid: Option<String>,
21 pub new_oid: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct HookEventDto {
26 pub kind: HookEventKind,
27 pub repo_id: String,
28 pub refs: Vec<HookRefDto>,
29 pub actor: String,
30 pub timestamp: i64,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct HookResultDto {
35 pub accepted: bool,
36 pub message: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize)]
40pub struct ScriptOutputDto {
41 pub exit_code: i32,
42 pub stdout: String,
43 pub stderr: String,
44}
45
46impl GitService {
47 pub async fn hook_pre_receive(&self, _event: HookEventDto) -> Result<HookResultDto, GitError> {
48 Ok(HookResultDto {
49 accepted: false,
50 message: Some("no pre-receive policy configured".to_string()),
51 })
52 }
53
54 pub async fn hook_post_receive(&self, _event: HookEventDto) -> Result<HookResultDto, GitError> {
55 Ok(HookResultDto {
56 accepted: true,
57 message: None,
58 })
59 }
60
61 pub fn hook_supported(&self, kind: HookEventKind) -> bool {
62 matches!(kind, HookEventKind::PreReceive | HookEventKind::PostReceive)
63 }
64
65 pub async fn hook_execute_script(
66 &self,
67 space_name: String,
68 repo_name: String,
69 script: String,
70 args: Vec<String>,
71 ) -> Result<ScriptOutputDto, GitError> {
72 if script.is_empty()
73 || script.contains('/')
74 || script.contains('\\')
75 || script.contains("..")
76 || script.starts_with('-')
77 {
78 return Err(GitError::InvalidArgument(format!(
79 "invalid script name `{script}`"
80 )));
81 }
82 let ctx = self.context(space_name, repo_name).await?;
83 let dir = ctx.repo_dir().join("hooks");
84 let mut cmd = std::process::Command::new(dir.join(&script));
85 for a in &args {
86 cmd.arg(a);
87 }
88 let out = tokio::task::spawn_blocking(move || cmd.output())
89 .await
90 .map_err(|e| GitError::Internal(format!("script spawn: {e}")))?
91 .map_err(|e| GitError::Internal(format!("script exec: {e}")))?;
92 Ok(ScriptOutputDto {
93 exit_code: out.status.code().unwrap_or(-1),
94 stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
95 stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
96 })
97 }
98}
Added lib/git/src/service/lfs.rs View lib/git/src/service/lfs.rs
@@ -0,0 +1,163 @@
1//! Thin delegation layer: keeps the stable `GitService` LFS API surface while
2//! all logic lives in [`crate::lfs`].
3
4use uuid::Uuid;
5
6use crate::lfs;
7pub use crate::lfs::types::*;
8use crate::service::GitService;
9use crate::share::errors::GitError;
10
11impl GitService {
12 pub async fn lfs_batch(
13 &self,
14 space_name: String,
15 repo_name: String,
16 req: LfsBatchRequest,
17 ) -> Result<LfsBatchResponse, GitError> {
18 lfs::batch(self, space_name, repo_name, req).await
19 }
20
21 #[allow(clippy::too_many_arguments)]
22 pub async fn lfs_upload(
23 &self,
24 space_name: String,
25 repo_name: String,
26 caller: Uuid,
27 oid: String,
28 size: i64,
29 content_type: Option<String>,
30 body: Vec<u8>,
31 ) -> Result<bool, GitError> {
32 lfs::upload(
33 self,
34 space_name,
35 repo_name,
36 caller,
37 oid,
38 size,
39 content_type,
40 body,
41 )
42 .await
43 }
44
45 #[allow(clippy::too_many_arguments)]
46 pub async fn lfs_upload_stream<R>(
47 &self,
48 space_name: String,
49 repo_name: String,
50 caller: Uuid,
51 oid: String,
52 size: i64,
53 content_type: Option<String>,
54 reader: R,
55 ) -> Result<bool, GitError>
56 where
57 R: tokio::io::AsyncRead + Unpin + Send,
58 {
59 lfs::upload_stream(
60 self,
61 space_name,
62 repo_name,
63 caller,
64 oid,
65 size,
66 content_type,
67 reader,
68 )
69 .await
70 }
71
72 pub async fn lfs_download_to<W>(
73 &self,
74 space_name: String,
75 repo_name: String,
76 oid: String,
77 writer: &mut W,
78 ) -> Result<u64, GitError>
79 where
80 W: tokio::io::AsyncWrite + Send + Unpin,
81 {
82 lfs::download_to(self, space_name, repo_name, oid, writer).await
83 }
84
85 pub async fn lfs_verify(
86 &self,
87 space_name: String,
88 repo_name: String,
89 req: LfsVerifyRequest,
90 ) -> Result<(), GitError> {
91 lfs::verify_object(self, space_name, repo_name, req).await
92 }
93
94 pub async fn lfs_download(
95 &self,
96 space_name: String,
97 repo_name: String,
98 oid: String,
99 ) -> Result<Vec<u8>, GitError> {
100 lfs::download(self, space_name, repo_name, oid).await
101 }
102
103 pub async fn lfs_lock(
104 &self,
105 space_name: String,
106 repo_name: String,
107 owner: Uuid,
108 path: String,
109 oid: Option<String>,
110 ) -> Result<LfsLock, GitError> {
111 lfs::create_lock(self, space_name, repo_name, owner, path, oid).await
112 }
113
114 pub async fn lfs_unlock(
115 &self,
116 space_name: String,
117 repo_name: String,
118 owner: Uuid,
119 path: String,
120 force: bool,
121 ) -> Result<(), GitError> {
122 lfs::unlock(self, space_name, repo_name, owner, path, force).await
123 }
124
125 pub async fn lfs_unlock_by_id(
126 &self,
127 space_name: String,
128 repo_name: String,
129 owner: Uuid,
130 lock_id: String,
131 force: bool,
132 ) -> Result<(), GitError> {
133 lfs::unlock_by_id(self, space_name, repo_name, owner, lock_id, force).await
134 }
135
136 pub async fn lfs_list_locks(
137 &self,
138 space_name: String,
139 repo_name: String,
140 cursor: Option<String>,
141 limit: u64,
142 ) -> Result<LfsLockListResponse, GitError> {
143 lfs::list_locks(self, space_name, repo_name, cursor, limit).await
144 }
145
146 pub async fn lfs_verify_locks(
147 &self,
148 space_name: String,
149 repo_name: String,
150 caller: Uuid,
151 limit: u64,
152 ) -> Result<LfsVerifyLocksResponse, GitError> {
153 lfs::verify_locks(self, space_name, repo_name, caller, limit).await
154 }
155
156 pub async fn lfs_cleanup_unreferenced(
157 &self,
158 space_name: String,
159 repo_name: String,
160 ) -> Result<i64, GitError> {
161 lfs::cleanup_unreferenced(self, space_name, repo_name).await
162 }
163}
Added lib/git/src/service/logs.rs View lib/git/src/service/logs.rs
@@ -0,0 +1,133 @@
1use gitbaby::commit::types::{CommitInfo, ListCommitsOptions};
2
3use crate::service::GitService;
4use crate::share::errors::{GitError, map_baby};
5
6#[derive(Debug, Clone, serde::Serialize)]
7pub struct LogListDto {
8 pub commits: Vec<CommitInfo>,
9 pub cursor: Option<String>,
10 pub has_more: bool,
11}
12
13#[derive(Debug, Clone, serde::Deserialize)]
14pub struct LogOptions {
15 #[serde(default)]
16 pub revision: Option<String>,
17 #[serde(default)]
18 pub limit: Option<u64>,
19 #[serde(default)]
20 pub skip: Option<u64>,
21}
22
23#[derive(Debug, Clone, serde::Serialize)]
24pub struct GraphNode {
25 pub commit: CommitInfo,
26 pub lanes: Vec<usize>,
27}
28
29const DEFAULT_LOG_LIMIT: u64 = 50;
30const MAX_LOG_LIMIT: u64 = 200;
31
32impl GitService {
33 pub async fn log_list(
34 &self,
35 space_name: String,
36 repo_name: String,
37 opts: LogOptions,
38 ) -> Result<LogListDto, GitError> {
39 let limit = opts
40 .limit
41 .unwrap_or(DEFAULT_LOG_LIMIT)
42 .clamp(1, MAX_LOG_LIMIT);
43 let skip = opts.skip.unwrap_or(0);
44 let revision = opts.revision.clone().unwrap_or_else(|| "HEAD".to_string());
45 let ctx = self.context(space_name, repo_name).await?;
46 let baby = self.baby(&ctx);
47
48 let max = limit + skip + 1;
49 let max_count = if max <= u32::MAX as u64 {
50 Some(max as u32)
51 } else {
52 None
53 };
54 let commits = baby
55 .list_commits(ListCommitsOptions {
56 revision: revision.clone(),
57 max_count,
58 ..Default::default()
59 })
60 .await
61 .map_err(map_baby)?;
62 let has_more = commits.len() > limit as usize + skip as usize;
63 let mut page = commits;
64 if page.len() > limit as usize + skip as usize {
65 page.truncate(limit as usize + skip as usize);
66 }
67 let page = page.into_iter().skip(skip as usize).collect::<Vec<_>>();
68 let cursor = page.last().map(|c| c.id.to_string());
69 let _ = revision;
70 Ok(LogListDto {
71 commits: page,
72 cursor: if has_more { cursor } else { None },
73 has_more,
74 })
75 }
76
77 pub async fn log_commits_between(
78 &self,
79 space_name: String,
80 repo_name: String,
81 after: String,
82 before: String,
83 limit: u64,
84 ) -> Result<LogListDto, GitError> {
85 let limit = limit.clamp(1, MAX_LOG_LIMIT);
86 let ctx = self.context(space_name, repo_name).await?;
87 let baby = self.baby(&ctx);
88 let commits = baby
89 .list_commits(ListCommitsOptions {
90 revision: format!("{after}..{before}"),
91 max_count: Some(limit as u32),
92 ..Default::default()
93 })
94 .await
95 .map_err(map_baby)?;
96 Ok(LogListDto {
97 commits,
98 cursor: None,
99 has_more: false,
100 })
101 }
102
103 pub async fn log_last_commit(
104 &self,
105 space_name: String,
106 repo_name: String,
107 branch: String,
108 ) -> Result<CommitInfo, GitError> {
109 let ctx = self.context(space_name, repo_name).await?;
110 let baby = self.baby(&ctx);
111 baby.get_commit(&branch).await.map_err(map_baby)
112 }
113
114 /// Commit graph; `lanes` are placeholders (matching the reference impl).
115 pub async fn log_graph(
116 &self,
117 space_name: String,
118 repo_name: String,
119 opts: LogOptions,
120 ) -> Result<Vec<GraphNode>, GitError> {
121 let page = self
122 .log_list(space_name, repo_name, opts)
123 .await?
124 .commits
125 .into_iter()
126 .map(|commit| GraphNode {
127 commit,
128 lanes: Vec::new(),
129 })
130 .collect();
131 Ok(page)
132 }
133}
Added lib/git/src/service/maintenance.rs View lib/git/src/service/maintenance.rs
@@ -0,0 +1,71 @@
1use std::process::Command;
2
3use serde::Serialize;
4
5use crate::service::GitService;
6use crate::share::errors::{GitError, map_baby};
7
8#[derive(Debug, Clone, Serialize)]
9pub struct PruneResultDto {
10 pub reachable: i64,
11 pub unreachable: i64,
12 pub total: i64,
13}
14
15#[derive(Debug, Clone, Serialize)]
16pub struct FsckDto {
17 pub ok: bool,
18 pub dangling: i64,
19 pub broken: i64,
20}
21
22impl GitService {
23 pub async fn maintenance_prune(
24 &self,
25 space_name: String,
26 repo_name: String,
27 ) -> Result<PruneResultDto, GitError> {
28 let ctx = self.context(space_name, repo_name).await?;
29 self.baby(&ctx).prune().await.map_err(map_baby)?;
30 let out = Command::new("git")
31 .arg("-C")
32 .arg(ctx.repo_dir())
33 .args(["count-objects", "-v"])
34 .output()
35 .map_err(|e| GitError::Internal(format!("count-objects: {e}")))?;
36 let mut total: i64 = 0;
37 if out.status.success() {
38 for line in String::from_utf8_lossy(&out.stdout).lines() {
39 if let Some(rest) = line.strip_prefix("count: ") {
40 total = rest.trim().parse().unwrap_or(0);
41 }
42 }
43 }
44 Ok(PruneResultDto {
45 reachable: total,
46 unreachable: 0,
47 total,
48 })
49 }
50
51 pub async fn maintenance_fsck(
52 &self,
53 space_name: String,
54 repo_name: String,
55 ) -> Result<FsckDto, GitError> {
56 let ctx = self.context(space_name, repo_name).await?;
57 let out = Command::new("git")
58 .arg("-C")
59 .arg(ctx.repo_dir())
60 .args(["fsck", "--no-progress", "--no-dangling"])
61 .output()
62 .map_err(|e| GitError::Internal(format!("fsck spawn: {e}")))?;
63 let stdout = String::from_utf8_lossy(&out.stdout);
64 let dangling = stdout.lines().filter(|l| l.contains("dangling")).count() as i64;
65 Ok(FsckDto {
66 ok: out.status.success(),
67 dangling,
68 broken: if out.status.success() { 0 } else { 1 },
69 })
70 }
71}
Added lib/git/src/service/mod.rs View lib/git/src/service/mod.rs
@@ -0,0 +1,57 @@
1use std::sync::Arc;
2
3use export::AppState;
4use gitbaby::GitBaby;
5use sea_orm::DatabaseConnection;
6
7use crate::context::facade::GitContext;
8
9#[derive(Clone)]
10pub struct GitService {
11 pub state: AppState,
12}
13
14impl GitService {
15 pub fn new(state: AppState) -> Self {
16 Self { state }
17 }
18
19 pub fn baby(&self, ctx: &GitContext) -> GitBaby {
20 GitBaby::new(Arc::new(ctx.clone()))
21 }
22
23 pub fn db_write(&self) -> &DatabaseConnection {
24 self.state.database.write()
25 }
26
27 pub fn db_read(&self) -> &DatabaseConnection {
28 self.state.database.read()
29 }
30}
31
32pub mod analysis;
33pub mod archives;
34pub mod blame;
35pub mod blobs;
36pub mod branches;
37pub mod cache;
38pub mod commits;
39pub mod conflicts;
40pub mod content;
41pub mod context;
42pub mod diffs;
43pub mod hooks;
44pub mod lfs;
45pub mod logs;
46pub mod maintenance;
47pub mod packs;
48pub mod patches;
49pub mod path_log;
50pub mod pipelines;
51pub mod refs;
52pub mod remotes;
53pub mod repos;
54pub mod signatures;
55pub mod submodules;
56pub mod tags;
57pub mod trees;
Added lib/git/src/service/packs.rs View lib/git/src/service/packs.rs
@@ -0,0 +1,114 @@
1use std::process::Command;
2
3use serde::Serialize;
4
5use crate::service::GitService;
6use crate::share::errors::{GitError, map_baby};
7
8#[derive(Debug, Clone, Serialize)]
9pub struct IngestPackDto {
10 pub object_count: i64,
11 pub size: i64,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct PackStatsDto {
16 pub loose_count: i64,
17 pub size_bytes: i64,
18}
19
20impl GitService {
21 pub async fn pack_ingest(
22 &self,
23 space_name: String,
24 repo_name: String,
25 pack: Vec<u8>,
26 ) -> Result<IngestPackDto, GitError> {
27 let ctx = self.context(space_name, repo_name).await?;
28 let dir = ctx.repo_dir();
29 let mut cmd = Command::new("git");
30 cmd.arg("-C").arg(&dir).args(["index-pack", "--stdin"]);
31 let mut child = cmd
32 .stdin(std::process::Stdio::piped())
33 .stdout(std::process::Stdio::piped())
34 .stderr(std::process::Stdio::piped())
35 .spawn()
36 .map_err(|e| GitError::Internal(format!("index-pack spawn: {e}")))?;
37 if let Some(mut stdin) = child.stdin.take() {
38 use std::io::Write;
39 stdin
40 .write_all(&pack)
41 .map_err(|e| GitError::Internal(format!("index-pack write: {e}")))?;
42 }
43 let out = child
44 .wait_with_output()
45 .map_err(|e| GitError::Internal(format!("index-pack wait: {e}")))?;
46 if !out.status.success() {
47 return Err(GitError::Internal(format!(
48 "index-pack failed: {}",
49 String::from_utf8_lossy(&out.stderr).trim()
50 )));
51 }
52 let mut object_count: i64 = 0;
53 for line in String::from_utf8_lossy(&out.stdout).lines() {
54 if let Some(rest) = line.strip_prefix("non delta: ")
55 && let Some(n) = rest.split_whitespace().next()
56 {
57 object_count = n.parse().unwrap_or(0);
58 }
59 }
60 let size = pack.len() as i64;
61 Ok(IngestPackDto { object_count, size })
62 }
63
64 pub async fn pack_repack(&self, space_name: String, repo_name: String) -> Result<(), GitError> {
65 let ctx = self.context(space_name, repo_name).await?;
66 let out = Command::new("git")
67 .arg("-C")
68 .arg(ctx.repo_dir())
69 .args(["repack", "-a", "-d", "-q"])
70 .output()
71 .map_err(|e| GitError::Internal(format!("repack: {e}")))?;
72 if !out.status.success() {
73 return Err(GitError::Internal(format!(
74 "repack failed: {}",
75 String::from_utf8_lossy(&out.stderr).trim()
76 )));
77 }
78 Ok(())
79 }
80
81 pub async fn pack_stats(
82 &self,
83 space_name: String,
84 repo_name: String,
85 ) -> Result<PackStatsDto, GitError> {
86 let ctx = self.context(space_name, repo_name).await?;
87 let out = Command::new("git")
88 .arg("-C")
89 .arg(ctx.repo_dir())
90 .args(["count-objects", "-v"])
91 .output()
92 .map_err(|e| GitError::Internal(format!("count-objects: {e}")))?;
93 if !out.status.success() {
94 return Err(GitError::Internal(format!(
95 "count-objects failed: {}",
96 String::from_utf8_lossy(&out.stderr).trim()
97 )));
98 }
99 let mut loose_count: i64 = 0;
100 let mut size: i64 = 0;
101 for line in String::from_utf8_lossy(&out.stdout).lines() {
102 if let Some(rest) = line.strip_prefix("count: ") {
103 loose_count = rest.trim().parse().unwrap_or(0);
104 } else if let Some(rest) = line.strip_prefix("size: ") {
105 size = rest.trim().parse().unwrap_or(0);
106 }
107 }
108 let _ = map_baby; // keep helper reachable
109 Ok(PackStatsDto {
110 loose_count,
111 size_bytes: size,
112 })
113 }
114}
Added lib/git/src/service/patches.rs View lib/git/src/service/patches.rs
@@ -0,0 +1,133 @@
1use std::process::Command;
2
3use serde::Serialize;
4use time::OffsetDateTime;
5
6use crate::service::GitService;
7use crate::share::errors::{GitError, map_baby};
8pub struct PatchDto {
9 pub content: String,
10 pub size: i64,
11}
12
13#[derive(Debug, Clone, Serialize)]
14pub struct ParsedPatchDto {
15 pub files: Vec<String>,
16 pub additions: i64,
17 pub deletions: i64,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct AppliedPatchDto {
22 pub applied: bool,
23 pub conflicts: Vec<String>,
24}
25
26impl GitService {
27 pub async fn patch_generate(
28 &self,
29 space_name: String,
30 repo_name: String,
31 base: String,
32 head: String,
33 ) -> Result<PatchDto, GitError> {
34 let ctx = self.context(space_name, repo_name).await?;
35 let body = self
36 .baby(&ctx)
37 .raw_diff(
38 &gitbaby::diff::types::DiffRequest {
39 left: self.baby(&ctx).rev_parse(&base).await.map_err(map_baby)?,
40 right: self.baby(&ctx).rev_parse(&head).await.map_err(map_baby)?,
41 },
42 None,
43 )
44 .await
45 .map_err(map_baby)?;
46 let size = body.len() as i64;
47 Ok(PatchDto {
48 content: body,
49 size,
50 })
51 }
52
53 pub async fn patch_parse(&self, content: String) -> Result<ParsedPatchDto, GitError> {
54 let mut files: Vec<String> = Vec::new();
55 let mut additions = 0i64;
56 let mut deletions = 0i64;
57 for line in content.lines() {
58 if let Some(rest) = line.strip_prefix("+++ b/") {
59 if rest != "/dev/null" {
60 files.push(rest.to_string());
61 }
62 } else if let Some(rest) = line.strip_prefix("--- a/") {
63 if rest == "/dev/null" && !files.iter().any(|f| f == "unknown") {
64 files.push("unknown".to_string());
65 }
66 } else if line.starts_with("+") && !line.starts_with("+++") {
67 additions += 1;
68 } else if line.starts_with("-") && !line.starts_with("---") {
69 deletions += 1;
70 }
71 }
72 files.sort();
73 files.dedup();
74 Ok(ParsedPatchDto {
75 files,
76 additions,
77 deletions,
78 })
79 }
80
81 pub async fn patch_apply(
82 &self,
83 space_name: String,
84 repo_name: String,
85 patch: String,
86 base: String,
87 ) -> Result<AppliedPatchDto, GitError> {
88 let ctx = self.context(space_name, repo_name).await?;
89 let baby = self.baby(&ctx);
90 // Resolve base to a concrete oid so we can hand it to `git apply --check`.
91 let base_oid = baby.rev_parse(&base).await.map_err(map_baby)?;
92 let dir = ctx.repo_dir();
93 let tmp = std::env::temp_dir().join(format!(
94 "gitdata-patch-{}-{}",
95 std::process::id(),
96 OffsetDateTime::now_utc().unix_timestamp_nanos()
97 ));
98 tokio::fs::create_dir_all(&tmp)
99 .await
100 .map_err(GitError::from)?;
101 let patch_path = tmp.join("patch.diff");
102 tokio::fs::write(&patch_path, patch.as_bytes())
103 .await
104 .map_err(GitError::from)?;
105 let out = tokio::task::spawn_blocking({
106 let dir = dir.clone();
107 let patch = patch_path.clone();
108 move || {
109 Command::new("git")
110 .arg("-C")
111 .arg(&dir)
112 .args(["apply", "--check", "--3way"])
113 .arg(&patch)
114 .output()
115 }
116 })
117 .await
118 .map_err(|e| GitError::Internal(format!("apply task: {e}")))?
119 .map_err(|e| GitError::Internal(format!("apply failed: {e}")))?;
120 let _ = base_oid;
121 let applied = out.status.success();
122 let conflicts = if applied {
123 Vec::new()
124 } else {
125 String::from_utf8_lossy(&out.stderr)
126 .lines()
127 .filter_map(|l| l.strip_prefix("error: ").map(|s| s.to_string()))
128 .collect()
129 };
130 let _ = tokio::fs::remove_dir_all(&tmp).await;
131 Ok(AppliedPatchDto { applied, conflicts })
132 }
133}
Added lib/git/src/service/path_log.rs View lib/git/src/service/path_log.rs
@@ -0,0 +1,132 @@
1use std::path::{Path, PathBuf};
2
3use gitbaby::commit::types::ListCommitsOptions;
4use serde::Serialize;
5
6use crate::service::GitService;
7use crate::share::errors::{GitError, map_baby};
8
9#[derive(Debug, Clone, Serialize)]
10pub struct PathLogEntryDto {
11 pub path: String,
12 pub short_sha: String,
13 pub date: String,
14 pub author: String,
15 pub subject: String,
16}
17
18fn validate_target_dir(path: &Path) -> Result<(), GitError> {
19 if path.as_os_str().is_empty() {
20 return Err(GitError::InvalidArgument(
21 "target directory path is empty".to_string(),
22 ));
23 }
24 if path.is_absolute() || path.has_root() {
25 return Err(GitError::InvalidArgument(format!(
26 "target directory must be relative: `{}`",
27 path.display()
28 )));
29 }
30 if path
31 .components()
32 .any(|c| matches!(c, std::path::Component::ParentDir))
33 {
34 return Err(GitError::InvalidArgument(format!(
35 "target directory may not contain `..`: `{}`",
36 path.display()
37 )));
38 }
39 Ok(())
40}
41
42fn iso_date(t: &time::OffsetDateTime) -> String {
43 let secs = t.offset().whole_seconds();
44 let sign = if secs >= 0 { '+' } else { '-' };
45 let abs = secs.unsigned_abs();
46 format!(
47 "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{:02}{:02}",
48 t.year(),
49 u8::from(t.month()),
50 t.day(),
51 t.hour(),
52 t.minute(),
53 t.second(),
54 abs / 3600,
55 (abs % 3600) / 60,
56 )
57}
58
59fn first_line(s: &str) -> String {
60 s.trim_end()
61 .lines()
62 .next()
63 .unwrap_or("")
64 .trim_end()
65 .to_string()
66}
67
68impl GitService {
69 pub async fn path_log(
70 &self,
71 space_name: String,
72 repo_name: String,
73 commit_hash: String,
74 target_dir: PathBuf,
75 ) -> Result<Vec<PathLogEntryDto>, GitError> {
76 validate_target_dir(&target_dir)?;
77 if commit_hash.trim().is_empty() {
78 return Err(GitError::InvalidArgument(
79 "commit hash must not be empty".to_string(),
80 ));
81 }
82
83 let ctx = self.context(space_name, repo_name).await?;
84 let baby = self.baby(&ctx);
85
86 let oid = baby
87 .rev_parse(&commit_hash)
88 .await
89 .map_err(map_baby)?
90 .to_string();
91
92 let entries = baby
93 .get_tree_entries(&oid, &target_dir)
94 .await
95 .map_err(map_baby)?;
96
97 let mut out = Vec::with_capacity(entries.len());
98 for entry in &entries {
99 if entry.name.starts_with('.') {
100 continue;
101 }
102 let entry_path = match target_dir.to_str() {
103 Some("") | Some(".") => entry.name.clone(),
104 Some(d) if d.ends_with('/') => format!("{d}{}", entry.name),
105 Some(d) => format!("{d}/{}", entry.name),
106 None => continue,
107 };
108 let commits = baby
109 .list_commits(ListCommitsOptions {
110 revision: oid.clone(),
111 path: Some(PathBuf::from(&entry_path)),
112 max_count: Some(1),
113 ..Default::default()
114 })
115 .await
116 .map_err(map_baby)?;
117 let Some(commit) = commits.into_iter().next() else {
118 continue;
119 };
120 let hex = commit.id.to_string();
121 let short_sha = hex.get(..7).unwrap_or("").to_string();
122 out.push(PathLogEntryDto {
123 path: entry_path,
124 short_sha,
125 date: iso_date(&commit.committer.time),
126 author: commit.author.name.clone(),
127 subject: first_line(&commit.message),
128 });
129 }
130 Ok(out)
131 }
132}
Added lib/git/src/service/pipelines.rs View lib/git/src/service/pipelines.rs
@@ -0,0 +1,80 @@
1use serde::Serialize;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5use crate::service::GitService;
6use crate::share::errors::GitError;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "lowercase")]
10pub enum CommitStatusState {
11 Pending,
12 Success,
13 Failure,
14 Error,
15 Warning,
16 Skipped,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct CommitStatusDto {
21 pub state: CommitStatusState,
22 pub context: String,
23 pub description: Option<String>,
24 pub target_url: Option<String>,
25 pub creator: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize)]
29pub struct JobDto {
30 pub id: String,
31 pub name: String,
32 pub state: CommitStatusState,
33 pub started_at: Option<OffsetDateTime>,
34 pub finished_at: Option<OffsetDateTime>,
35}
36
37#[derive(Debug, Clone, Serialize)]
38pub struct TriggerResultDto {
39 pub job_id: String,
40 pub queued: bool,
41}
42
43#[derive(Debug, Clone, Serialize)]
44pub struct StatusListDto {
45 pub statuses: Vec<CommitStatusDto>,
46 pub total: u64,
47}
48
49impl GitService {
50 pub async fn pipeline_trigger(
51 &self,
52 _space_name: String,
53 _repo_name: String,
54 _commit: String,
55 _branch: String,
56 ) -> Result<TriggerResultDto, GitError> {
57 Ok(TriggerResultDto {
58 job_id: Uuid::now_v7().to_string(),
59 queued: false,
60 })
61 }
62
63 pub async fn pipeline_schedule(
64 &self,
65 _space_name: String,
66 _repo_name: String,
67 _commit: String,
68 _job: JobDto,
69 ) -> Result<(), GitError> {
70 Err(GitError::Internal(
71 "pipeline scheduling not supported by this backend".to_string(),
72 ))
73 }
74
75 pub async fn pipeline_cancel(&self, _job_id: String) -> Result<(), GitError> {
76 Err(GitError::Internal(
77 "pipeline cancellation not supported by this backend".to_string(),
78 ))
79 }
80}
Added lib/git/src/service/refs.rs View lib/git/src/service/refs.rs
@@ -0,0 +1,235 @@
1use gitbaby::refs::types::{ListRefsOptions, RefInfo, RefType};
2use serde::Serialize;
3
4use crate::service::GitService;
5use crate::share::errors::{GitError, map_baby};
6use crate::share::policy::load_branch_policy;
7use crate::share::validate::validate_qualified_ref;
8
9#[derive(Debug, Clone, Serialize)]
10pub struct HeadInfoDto {
11 pub name: String,
12 pub object: String,
13 pub object_type: i8,
14 pub is_detached: bool,
15}
16
17#[derive(Debug, Clone, Serialize)]
18pub struct BranchInfoDto {
19 pub name: String,
20 pub commit_sha: String,
21 pub commit_message: Option<String>,
22 pub is_protected: bool,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct UpdateRefResultDto {
27 pub name: String,
28 pub old_oid: Option<String>,
29 pub new_oid: String,
30 pub protected: bool,
31}
32
33impl GitService {
34 pub async fn refs_list(
35 &self,
36 space_name: String,
37 repo_name: String,
38 ) -> Result<Vec<RefInfo>, GitError> {
39 let ctx = self.context(space_name, repo_name).await?;
40 self.baby(&ctx)
41 .list_refs(ListRefsOptions::default())
42 .await
43 .map_err(map_baby)
44 }
45
46 pub async fn refs_list_filtered(
47 &self,
48 space_name: String,
49 repo_name: String,
50 pattern: String,
51 ) -> Result<Vec<RefInfo>, GitError> {
52 let ctx = self.context(space_name, repo_name).await?;
53 self.baby(&ctx)
54 .list_refs(ListRefsOptions {
55 patterns: vec![pattern],
56 ..Default::default()
57 })
58 .await
59 .map_err(map_baby)
60 }
61
62 pub async fn refs_get(
63 &self,
64 space_name: String,
65 repo_name: String,
66 name: String,
67 ) -> Result<RefInfo, GitError> {
68 let ctx = self.context(space_name, repo_name).await?;
69 let refs = self
70 .baby(&ctx)
71 .list_refs(ListRefsOptions {
72 patterns: vec![name],
73 ..Default::default()
74 })
75 .await
76 .map_err(map_baby)?;
77 refs.into_iter().next().ok_or(GitError::NotFound)
78 }
79
80 pub async fn refs_resolve(
81 &self,
82 space_name: String,
83 repo_name: String,
84 name: String,
85 ) -> Result<String, GitError> {
86 let ctx = self.context(space_name, repo_name).await?;
87 self.baby(&ctx)
88 .resolve_revision(&name)
89 .await
90 .map(|oid| oid.to_string())
91 .map_err(map_baby)
92 }
93
94 pub async fn refs_head(
95 &self,
96 space_name: String,
97 repo_name: String,
98 ) -> Result<HeadInfoDto, GitError> {
99 let ctx = self.context(space_name, repo_name).await?;
100 let baby = self.baby(&ctx);
101 let name = baby.get_default_branch().await.map_err(map_baby)?;
102 let object = baby
103 .resolve_revision(&name)
104 .await
105 .map_err(map_baby)?
106 .to_string();
107 Ok(HeadInfoDto {
108 name,
109 object,
110 object_type: 0,
111 is_detached: false,
112 })
113 }
114
115 pub async fn refs_branches(
116 &self,
117 space_name: String,
118 repo_name: String,
119 skip: u64,
120 limit: u64,
121 ) -> Result<Vec<BranchInfoDto>, GitError> {
122 let ctx = self.context(space_name, repo_name).await?;
123 let baby = self.baby(&ctx);
124 let branches = baby
125 .list_branches(gitbaby::branch::ListBranchesOptions::default())
126 .await
127 .map_err(map_baby)?;
128 let policy = load_branch_policy(&self.state.database, ctx.model.id).await?;
129 let mut out: Vec<BranchInfoDto> = branches
130 .into_iter()
131 .map(|b| BranchInfoDto {
132 name: b.name.clone(),
133 commit_sha: b.sha.to_string(),
134 commit_message: None,
135 is_protected: policy.is_match(&b.name),
136 })
137 .collect();
138 out.sort_by(|a, b| a.name.cmp(&b.name));
139 if let Some(start) = skip.checked_sub(1) {
140 out = out.into_iter().skip(start as usize).collect();
141 }
142 if limit > 0 {
143 out.truncate(limit as usize);
144 }
145 Ok(out)
146 }
147
148 pub async fn refs_update(
149 &self,
150 space_name: String,
151 repo_name: String,
152 name: String,
153 new_oid: String,
154 expected_oid: Option<String>,
155 ) -> Result<UpdateRefResultDto, GitError> {
156 validate_qualified_ref(&name)?;
157 let ctx = self.context(space_name, repo_name).await?;
158 if self.ref_protected(&ctx, &name).await? {
159 return Err(GitError::Forbidden);
160 }
161 let baby = self.baby(&ctx);
162 let new = crate::service::diffs::parse_oid(&new_oid)?;
163 let old: Option<gix::ObjectId> = match expected_oid {
164 Some(hex) => Some(crate::service::diffs::parse_oid(&hex)?),
165 None => None,
166 };
167 baby.update_ref(&name, new, old).await.map_err(map_baby)?;
168 Ok(UpdateRefResultDto {
169 name,
170 old_oid: old.map(|o| o.to_string()),
171 new_oid: new.to_string(),
172 protected: false,
173 })
174 }
175
176 pub async fn refs_delete(
177 &self,
178 space_name: String,
179 repo_name: String,
180 name: String,
181 ) -> Result<(), GitError> {
182 validate_qualified_ref(&name)?;
183 let ctx = self.context(space_name, repo_name).await?;
184 if self.ref_protected(&ctx, &name).await? {
185 return Err(GitError::Forbidden);
186 }
187 self.baby(&ctx).delete_ref(&name).await.map_err(map_baby)
188 }
189
190 pub async fn refs_is_object_in_ref(
191 &self,
192 space_name: String,
193 repo_name: String,
194 object: String,
195 refname: String,
196 ) -> Result<bool, GitError> {
197 let ctx = self.context(space_name, repo_name).await?;
198 let oid = crate::service::diffs::parse_oid(&object)?;
199 let refs = self
200 .baby(&ctx)
201 .find_refs_by_oid(oid)
202 .await
203 .map_err(map_baby)?;
204 Ok(refs.into_iter().any(|r| r.name == refname))
205 }
206
207 pub async fn refs_classify(&self, name: &str) -> RefType {
208 if name.starts_with("refs/heads/") {
209 RefType::Branch
210 } else if name.starts_with("refs/tags/") {
211 RefType::Tag
212 } else if name.starts_with("refs/remotes/") {
213 RefType::Remote
214 } else {
215 RefType::Other
216 }
217 }
218
219 async fn ref_protected(
220 &self,
221 ctx: &crate::context::facade::GitContext,
222 name: &str,
223 ) -> Result<bool, GitError> {
224 if let Some(branch) = name.strip_prefix("refs/heads/") {
225 let policy = load_branch_policy(&self.state.database, ctx.model.id).await?;
226 return Ok(policy.is_match(branch));
227 }
228 if let Some(tag) = name.strip_prefix("refs/tags/") {
229 let policy =
230 crate::share::policy::load_tag_policy(&self.state.database, ctx.model.id).await?;
231 return Ok(policy.is_match(tag));
232 }
233 Ok(false)
234 }
235}
Added lib/git/src/service/remotes.rs View lib/git/src/service/remotes.rs
@@ -0,0 +1,208 @@
1use serde::{Deserialize, Serialize};
2use std::process::Command;
3
4use crate::service::GitService;
5use crate::share::errors::{GitError, map_baby};
6use crate::share::validate::validate_remote_url;
7
8#[derive(Debug, Clone, Serialize)]
9pub struct ImportResultDto {
10 pub default_branch: String,
11 pub commit_count: i64,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct FetchResultDto {
16 pub updated: Vec<String>,
17 pub new: Vec<String>,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct PushResultDto {
22 pub pushed: Vec<String>,
23 pub rejected: Vec<String>,
24}
25
26#[derive(Debug, Clone, Deserialize)]
27pub struct RemoteAuthDto {
28 #[serde(default)]
29 pub token: Option<String>,
30}
31
32#[derive(Debug, Clone, Deserialize)]
33pub struct RemoteConfigDto {
34 pub url: String,
35 #[serde(default)]
36 pub auth: Option<RemoteAuthDto>,
37 #[serde(default)]
38 pub name: Option<String>,
39 #[serde(default)]
40 pub refspec: Option<String>,
41}
42
43impl GitService {
44 pub async fn remote_clone_import(
45 &self,
46 space_name: String,
47 repo_name: String,
48 url: String,
49 _auth: Option<RemoteAuthDto>,
50 ) -> Result<ImportResultDto, GitError> {
51 validate_remote_url(&url)?;
52 let ctx = self.context(space_name, repo_name).await?;
53 let dir = ctx.repo_dir();
54 let out = tokio::task::spawn_blocking({
55 let dir = dir.clone();
56 let url = url.clone();
57 move || {
58 Command::new("git")
59 .arg("-C")
60 .arg(&dir)
61 .args(["fetch", "--mirror"])
62 .arg(&url)
63 .output()
64 }
65 })
66 .await
67 .map_err(|e| GitError::Internal(format!("import task: {e}")))?
68 .map_err(|e| GitError::Internal(format!("fetch mirror failed: {e}")))?;
69 if !out.status.success() {
70 return Err(GitError::Internal(format!(
71 "import failed: {}",
72 String::from_utf8_lossy(&out.stderr).trim()
73 )));
74 }
75 let baby = self.baby(&ctx);
76 let default = baby.get_default_branch().await.map_err(map_baby)?;
77 let commit_count = count_refs(&dir).await?;
78 Ok(ImportResultDto {
79 default_branch: default,
80 commit_count,
81 })
82 }
83
84 pub async fn remote_fetch(
85 &self,
86 space_name: String,
87 repo_name: String,
88 remote: String,
89 auth: Option<RemoteAuthDto>,
90 ) -> Result<FetchResultDto, GitError> {
91 let ctx = self.context(space_name, repo_name).await?;
92 let mut cmd = Command::new("git");
93 cmd.arg("-C").arg(ctx.repo_dir());
94 if let Some(a) = &auth
95 && let Some(t) = &a.token
96 {
97 cmd.env("GIT_ASKPASS", "/bin/true");
98 cmd.env("GIT_TOKEN", t);
99 }
100 cmd.args(["fetch", "--prune", &remote]);
101 let out = tokio::task::spawn_blocking(move || cmd.output())
102 .await
103 .map_err(|e| GitError::Internal(format!("fetch task: {e}")))?
104 .map_err(|e| GitError::Internal(format!("fetch failed: {e}")))?;
105 let updated: Vec<String> = String::from_utf8_lossy(&out.stdout)
106 .lines()
107 .filter_map(|l| l.split_whitespace().nth(1).map(|s| s.to_string()))
108 .collect();
109 Ok(FetchResultDto {
110 updated,
111 new: Vec::new(),
112 })
113 }
114
115 pub async fn remote_push(
116 &self,
117 space_name: String,
118 repo_name: String,
119 remote: String,
120 refspec: Option<String>,
121 auth: Option<RemoteAuthDto>,
122 ) -> Result<PushResultDto, GitError> {
123 let ctx = self.context(space_name, repo_name).await?;
124 let mut cmd = Command::new("git");
125 cmd.arg("-C").arg(ctx.repo_dir());
126 if let Some(a) = &auth
127 && let Some(t) = &a.token
128 {
129 cmd.env("GIT_ASKPASS", "/bin/true");
130 cmd.env("GIT_TOKEN", t);
131 }
132 cmd.args(["push", &remote]);
133 if let Some(r) = refspec {
134 cmd.arg(r);
135 }
136 let out = tokio::task::spawn_blocking(move || cmd.output())
137 .await
138 .map_err(|e| GitError::Internal(format!("push task: {e}")))?
139 .map_err(|e| GitError::Internal(format!("push failed: {e}")))?;
140 let stdout = String::from_utf8_lossy(&out.stdout);
141 let pushed: Vec<String> = stdout
142 .lines()
143 .filter_map(|l| {
144 l.strip_prefix(" ")
145 .map(|s| s.to_string())
146 .or(Some(l.to_string()))
147 })
148 .filter(|l| l.contains("->"))
149 .collect();
150 let rejected: Vec<String> = stdout
151 .lines()
152 .filter(|l| l.starts_with("!"))
153 .map(|l| l.to_string())
154 .collect();
155 if !out.status.success() && pushed.is_empty() {
156 return Err(GitError::Internal(format!(
157 "push failed: {}",
158 String::from_utf8_lossy(&out.stderr).trim()
159 )));
160 }
161 Ok(PushResultDto { pushed, rejected })
162 }
163
164 pub async fn remote_add(
165 &self,
166 space_name: String,
167 repo_name: String,
168 name: String,
169 url: String,
170 ) -> Result<(), GitError> {
171 validate_remote_url(&url)?;
172 let ctx = self.context(space_name, repo_name).await?;
173 self.baby(&ctx)
174 .add_remote(&name, &url, gitbaby::remote::types::RemoteOption::default())
175 .await
176 .map_err(map_baby)
177 }
178
179 pub async fn remote_remove(
180 &self,
181 space_name: String,
182 repo_name: String,
183 name: String,
184 ) -> Result<(), GitError> {
185 let ctx = self.context(space_name, repo_name).await?;
186 self.baby(&ctx).remove_remote(&name).await.map_err(map_baby)
187 }
188}
189
190async fn count_refs(dir: &std::path::Path) -> Result<i64, GitError> {
191 let dir = dir.to_path_buf();
192 let total = tokio::task::spawn_blocking(move || {
193 let out = Command::new("git")
194 .arg("-C")
195 .arg(&dir)
196 .args(["for-each-ref", "--count", "--format=%(objectname)"])
197 .output();
198 match out {
199 Ok(o) if o.status.success() => {
200 String::from_utf8_lossy(&o.stdout).lines().count() as i64
201 }
202 _ => 0,
203 }
204 })
205 .await
206 .map_err(|e| GitError::Internal(format!("count task: {e}")))?;
207 Ok(total)
208}
Added lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -0,0 +1,490 @@
1use std::path::Path;
2
3use gitbaby::command::cmd::Cmd;
4use gitbaby::command::env::Env;
5use gitbaby::refs::types::{ListRefsOptions, RefType};
6use gitbaby::setup::types::{FastImportCommit, ObjectFormat};
7use models::dir::dirs::{self, DirKind};
8use models::repo::repos;
9use models::space::spaces;
10use sea_orm::{
11 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
12 Unchanged,
13};
14use serde::{Deserialize, Serialize};
15use time::OffsetDateTime;
16use uuid::Uuid;
17
18use crate::context::facade::GitContext;
19use crate::service::GitService;
20use crate::share::errors::{GitError, map_baby};
21use crate::share::identity::load_identity;
22use crate::share::validate::{validate_branch_name, validate_repo_name};
23
24pub const DEFAULT_BRANCH: &str = "main";
25
26#[derive(Debug, Clone, Serialize)]
27pub struct RepoDto {
28 pub id: Uuid,
29 pub dir_id: Uuid,
30 pub name: String,
31 pub description: Option<String>,
32 pub website: Option<String>,
33 pub default_branch: String,
34 pub nums_watch: i32,
35 pub nums_star: i32,
36 pub nums_pr: i32,
37 pub nums_commits: i32,
38 pub nums_branches: i32,
39 pub nums_tags: i32,
40 pub enable_lfs: bool,
41 pub is_private: bool,
42 pub is_archived: bool,
43 pub storage_limit_size: Option<i64>,
44 pub size: i64,
45 pub git_size: i64,
46 pub lfs_size: i64,
47 pub avatar_url: Option<String>,
48 pub primary_language: Option<String>,
49 pub license_name: Option<String>,
50 pub object_format: String,
51 pub created_at: OffsetDateTime,
52 pub updated_at: OffsetDateTime,
53 pub last_pushed_at: Option<OffsetDateTime>,
54 pub archived_at: Option<OffsetDateTime>,
55}
56
57impl From<repos::Model> for RepoDto {
58 fn from(m: repos::Model) -> Self {
59 Self {
60 id: m.id,
61 dir_id: m.dir_id,
62 name: m.name,
63 description: m.description,
64 website: m.website,
65 default_branch: m.default_branch,
66 nums_watch: m.nums_watch,
67 nums_star: m.nums_star,
68 nums_pr: m.nums_pr,
69 nums_commits: m.nums_commits,
70 nums_branches: m.nums_branches,
71 nums_tags: m.nums_tags,
72 enable_lfs: m.enable_lfs,
73 is_private: m.is_private,
74 is_archived: m.is_archived,
75 storage_limit_size: m.storage_limit_size,
76 size: m.size,
77 git_size: m.git_size,
78 lfs_size: m.lfs_size,
79 avatar_url: m.avatar_url,
80 primary_language: m.primary_language,
81 license_name: m.license_name,
82 object_format: m.object_format,
83 created_at: m.created_at,
84 updated_at: m.updated_at,
85 last_pushed_at: m.last_pushed_at,
86 archived_at: m.archived_at,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Deserialize)]
92pub struct CreateRepoCmd {
93 pub name: String,
94 #[serde(default)]
95 pub description: Option<String>,
96 #[serde(default)]
97 pub is_private: bool,
98 #[serde(default)]
99 pub default_branch: Option<String>,
100 #[serde(default)]
101 pub parent_dir_id: Option<Uuid>,
102 #[serde(default)]
103 pub enable_lfs: bool,
104 #[serde(default)]
105 pub license_name: Option<String>,
106 #[serde(default)]
107 pub init_message: Option<String>,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111pub struct UpdateRepoCmd {
112 #[serde(default)]
113 pub description: Option<String>,
114 #[serde(default)]
115 pub website: Option<String>,
116 #[serde(default)]
117 pub is_private: Option<bool>,
118 #[serde(default)]
119 pub enable_lfs: Option<bool>,
120 #[serde(default)]
121 pub default_branch: Option<String>,
122}
123
124impl GitService {
125 async fn lookup_space(&self, space_name: &str) -> Result<spaces::Model, GitError> {
126 let lower = space_name.trim().to_lowercase();
127 if lower.is_empty() {
128 return Err(GitError::NotFound);
129 }
130 spaces::Entity::find()
131 .filter(spaces::Column::LowerName.eq(&lower))
132 .filter(spaces::Column::IsActive.eq(true))
133 .one(self.db_read())
134 .await?
135 .ok_or(GitError::NotFound)
136 }
137
138 pub async fn create_repo(
139 &self,
140 space_name: String,
141 caller: Uuid,
142 cmd: CreateRepoCmd,
143 ) -> Result<RepoDto, GitError> {
144 let space = self.lookup_space(&space_name).await?;
145 if space.owner_id != caller {
146 return Err(GitError::Forbidden);
147 }
148 let name = validate_repo_name(&cmd.name)?;
149 let default_branch =
150 validate_branch_name(cmd.default_branch.as_deref().unwrap_or(DEFAULT_BRANCH))?;
151 if let Some(p) = cmd.parent_dir_id {
152 let parent = dirs::Entity::find_by_id(p)
153 .filter(dirs::Column::SpaceId.eq(space.id))
154 .one(self.db_read())
155 .await?
156 .ok_or(GitError::NotFound)?;
157 if parent.kind != DirKind::Dir {
158 return Err(GitError::InvalidArgument(
159 "parent node must be a dir".to_string(),
160 ));
161 }
162 }
163
164 let (dir, repo) = {
165 let txn = self.db_write().begin().await?;
166 let position = {
167 let max_node = dirs::Entity::find()
168 .filter(dirs::Column::SpaceId.eq(space.id))
169 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
170 .order_by_desc(dirs::Column::Position)
171 .one(&txn)
172 .await?;
173 max_node.map(|n| n.position + 1).unwrap_or(0)
174 };
175 let now = OffsetDateTime::now_utc();
176 let dir_row = dirs::ActiveModel {
177 id: Set(Uuid::now_v7()),
178 space_id: Set(Some(space.id)),
179 name: Set(name.clone()),
180 kind: Set(DirKind::Object),
181 parent_dir_id: Set(cmd.parent_dir_id),
182 color: Set(None),
183 position: Set(position),
184 icon: Set(None),
185 topic: Set(None),
186 purpose: Set(None),
187 is_archived: Set(false),
188 archived_at: Set(None),
189 archived_by: Set(None),
190 created_at: Set(now),
191 updated_at: Set(now),
192 created_by_uid: Set(caller),
193 };
194 let dir = dir_row.insert(&txn).await?;
195
196 let repo_row = repos::ActiveModel {
197 id: Set(Uuid::now_v7()),
198 dir_id: Set(dir.id),
199 lower_name: Set(name.to_lowercase()),
200 name: Set(name),
201 description: Set(cmd.description.clone()),
202 website: Set(None),
203 default_branch: Set(default_branch.clone()),
204 nums_watch: Set(0),
205 nums_star: Set(0),
206 nums_pr: Set(0),
207 nums_commits: Set(0),
208 nums_branches: Set(0),
209 nums_tags: Set(0),
210 enable_lfs: Set(cmd.enable_lfs),
211 is_private: Set(cmd.is_private),
212 is_archived: Set(false),
213 storage_limit_size: Set(None),
214 size: Set(0),
215 git_size: Set(0),
216 lfs_size: Set(0),
217 avatar_url: Set(None),
218 primary_language: Set(None),
219 license_name: Set(cmd.license_name.clone()),
220 created_at: Set(now),
221 updated_at: Set(now),
222 last_pushed_at: Set(None),
223 archived_at: Set(None),
224 readme_raw: Set(None),
225 allow_rebase: Set(true),
226 allow_rebase_explicit: Set(false),
227 allow_squash_merge: Set(true),
228 allow_merge_commits: Set(true),
229 object_format: Set("sha1".to_string()),
230 };
231 let repo = repo_row.insert(&txn).await?;
232 txn.commit().await?;
233 (dir, repo)
234 };
235
236 let cache = std::sync::Arc::new(cache::CacheClient::new(&self.state.config.cache).await?);
237 let git_cfg = &self.state.config.git;
238 let ctx = GitContext {
239 model: repo.clone(),
240 cache,
241 database: self.state.database.clone(),
242 repo_root: std::path::PathBuf::from(&git_cfg.repo_root),
243 objects_root: std::path::PathBuf::from(&git_cfg.objects_root),
244 };
245 let repo_dir = ctx.repo_dir();
246
247 let init_result: Result<(), GitError> = async {
248 tokio::fs::create_dir_all(&repo_dir)
249 .await
250 .map_err(|e| GitError::Internal(format!("mkdir {}: {e}", repo_dir.display())))?;
251 let baby = self.baby(&ctx);
252 baby.init_repository(&repo_dir.to_string_lossy(), true, Some(ObjectFormat::Sha1))
253 .await
254 .map_err(map_baby)?;
255
256 let identity = load_identity(&self.state.database, caller).await?;
257 baby.config_set("user.name", &identity.name)
258 .await
259 .map_err(map_baby)?;
260 baby.config_set("user.email", &identity.email)
261 .await
262 .map_err(map_baby)?;
263
264 let init_msg = cmd
265 .init_message
266 .unwrap_or_else(|| "Initial commit".to_string());
267 let now = OffsetDateTime::now_utc();
268 let commit = FastImportCommit {
269 ref_name: format!("refs/heads/{default_branch}"),
270 message: Some(init_msg),
271 committer_name: identity.name,
272 committer_email: identity.email,
273 committer_time: now.unix_timestamp(),
274 committer_tz_offset: 0,
275 files: Vec::new(),
276 };
277 baby.fast_import(&repo_dir, vec![commit])
278 .await
279 .map_err(map_baby)?;
280 Ok(())
281 }
282 .await;
283
284 if let Err(err) = init_result {
285 let _ = tokio::fs::remove_dir_all(&repo_dir).await;
286 let _ = repos::Entity::delete_by_id(repo.id)
287 .exec(self.db_write())
288 .await;
289 let _ = dirs::Entity::delete_by_id(dir.id)
290 .exec(self.db_write())
291 .await;
292 return Err(err);
293 }
294
295 self.sync_repo_stats(&ctx).await?;
296 let fresh = repos::Entity::find_by_id(repo.id)
297 .one(self.db_read())
298 .await?
299 .ok_or(GitError::NotFound)?;
300 Ok(RepoDto::from(fresh))
301 }
302
303 pub async fn get_repo(
304 &self,
305 space_name: String,
306 repo_name: String,
307 ) -> Result<RepoDto, GitError> {
308 let ctx = self.context(space_name, repo_name).await?;
309 Ok(RepoDto::from(ctx.model))
310 }
311
312 pub async fn list_repos(&self, space_name: String) -> Result<Vec<RepoDto>, GitError> {
313 let space = self.lookup_space(&space_name).await?;
314 let dirs = dirs::Entity::find()
315 .filter(dirs::Column::SpaceId.eq(space.id))
316 .filter(dirs::Column::Kind.eq(DirKind::Object))
317 .all(self.db_read())
318 .await?;
319 let dir_ids: Vec<Uuid> = dirs.iter().map(|d| d.id).collect();
320 let rows = repos::Entity::find()
321 .filter(repos::Column::DirId.is_in(dir_ids))
322 .order_by_asc(repos::Column::CreatedAt)
323 .all(self.db_read())
324 .await?;
325 Ok(rows.into_iter().map(RepoDto::from).collect())
326 }
327
328 pub async fn update_repo(
329 &self,
330 space_name: String,
331 repo_name: String,
332 caller: Uuid,
333 cmd: UpdateRepoCmd,
334 ) -> Result<RepoDto, GitError> {
335 let ctx = self.context(space_name, repo_name).await?;
336 let space = self.repo_space(&ctx).await?;
337 if space.owner_id != caller {
338 return Err(GitError::Forbidden);
339 }
340 let baby = self.baby(&ctx);
341 if let Some(branch) = &cmd.default_branch {
342 let branch = validate_branch_name(branch)?;
343 if branch != ctx.model.default_branch {
344 baby.set_default_branch(&branch).await.map_err(map_baby)?;
345 }
346 }
347 let now = OffsetDateTime::now_utc();
348 let mut am = repos::ActiveModel {
349 id: Unchanged(ctx.model.id),
350 ..Default::default()
351 };
352 if let Some(desc) = cmd.description {
353 am.description = Set(Some(desc));
354 }
355 if let Some(website) = cmd.website {
356 am.website = Set(Some(website));
357 }
358 if let Some(private) = cmd.is_private {
359 am.is_private = Set(private);
360 }
361 if let Some(lfs) = cmd.enable_lfs {
362 am.enable_lfs = Set(lfs);
363 }
364 if let Some(branch) = cmd.default_branch {
365 let branch = validate_branch_name(&branch)?;
366 am.default_branch = Set(branch);
367 }
368 am.updated_at = Set(now);
369 let saved = am.update(self.db_write()).await?;
370 Ok(RepoDto::from(saved))
371 }
372
373 pub async fn archive_repo(
374 &self,
375 space_name: String,
376 repo_name: String,
377 caller: Uuid,
378 ) -> Result<RepoDto, GitError> {
379 let ctx = self.context(space_name, repo_name).await?;
380 let space = self.repo_space(&ctx).await?;
381 if space.owner_id != caller {
382 return Err(GitError::Forbidden);
383 }
384 if ctx.model.is_archived {
385 return Err(GitError::Conflict("repo is already archived".to_string()));
386 }
387 let now = OffsetDateTime::now_utc();
388 let am = repos::ActiveModel {
389 id: Unchanged(ctx.model.id),
390 is_archived: Set(true),
391 archived_at: Set(Some(now)),
392 updated_at: Set(now),
393 ..Default::default()
394 };
395 let saved = am.update(self.db_write()).await?;
396 Ok(RepoDto::from(saved))
397 }
398
399 async fn repo_space(&self, ctx: &GitContext) -> Result<spaces::Model, GitError> {
400 let mut dir_id = ctx.model.dir_id;
401 loop {
402 let dir = dirs::Entity::find_by_id(dir_id)
403 .one(self.db_read())
404 .await?
405 .ok_or(GitError::NotFound)?;
406 if let Some(space_id) = dir.space_id {
407 return spaces::Entity::find_by_id(space_id)
408 .one(self.db_read())
409 .await?
410 .ok_or(GitError::NotFound);
411 }
412 match dir.parent_dir_id {
413 Some(parent) => dir_id = parent,
414 None => return Err(GitError::NotFound),
415 }
416 }
417 }
418
419 pub async fn sync_repo_stats(&self, ctx: &GitContext) -> Result<(), GitError> {
420 let baby = self.baby(ctx);
421 let refs = baby
422 .list_refs(ListRefsOptions::default())
423 .await
424 .map_err(map_baby)?;
425 let nums_branches = refs
426 .iter()
427 .filter(|r| r.ref_type == RefType::Branch)
428 .count() as i32;
429 let nums_tags = refs.iter().filter(|r| r.ref_type == RefType::Tag).count() as i32;
430 let nums_commits = count_commits(&ctx.repo_dir()).await? as i32;
431 let git_size = dir_size(&ctx.repo_dir()).await? as i64;
432
433 let now = OffsetDateTime::now_utc();
434 let am = repos::ActiveModel {
435 id: Unchanged(ctx.model.id),
436 nums_branches: Set(nums_branches),
437 nums_tags: Set(nums_tags),
438 nums_commits: Set(nums_commits),
439 git_size: Set(git_size),
440 updated_at: Set(now),
441 ..Default::default()
442 };
443 am.update(self.db_write()).await?;
444 Ok(())
445 }
446}
447
448pub(crate) async fn count_commits(dir: &Path) -> Result<i64, GitError> {
449 let mut cmd = Cmd::new(
450 "gitdata::git:count_commits",
451 "git",
452 vec![
453 "rev-list".to_string(),
454 "--count".to_string(),
455 "--all".to_string(),
456 ],
457 Vec::new(),
458 dir.to_path_buf(),
459 Env::new(),
460 None,
461 );
462 let output = cmd
463 .run()
464 .await
465 .map_err(|e| GitError::Internal(format!("rev-list failed: {e}")))?;
466 if !output.status.success() {
467 return Err(GitError::Internal(format!(
468 "rev-list failed: {}",
469 String::from_utf8_lossy(&output.stderr).trim()
470 )));
471 }
472 let text = String::from_utf8_lossy(&output.stdout);
473 text.trim()
474 .parse::<i64>()
475 .map_err(|e| GitError::Internal(format!("parse commit count `{text}`: {e}")))
476}
477
478pub(crate) async fn dir_size(path: &Path) -> std::io::Result<u64> {
479 let mut total = 0u64;
480 let mut entries = tokio::fs::read_dir(path).await?;
481 while let Some(entry) = entries.next_entry().await? {
482 let meta = entry.metadata().await?;
483 if meta.is_dir() {
484 total += Box::pin(dir_size(&entry.path())).await?;
485 } else {
486 total += meta.len();
487 }
488 }
489 Ok(total)
490}
Added lib/git/src/service/signatures.rs View lib/git/src/service/signatures.rs
@@ -0,0 +1,83 @@
1use std::process::Command;
2
3use serde::Serialize;
4
5use crate::service::GitService;
6use crate::share::errors::{GitError, map_baby};
7
8#[derive(Debug, Clone, Serialize)]
9pub struct VerifyResultDto {
10 pub signed: bool,
11 pub valid: bool,
12 pub key_id: Option<String>,
13 pub signer: Option<String>,
14 pub error_message: Option<String>,
15}
16
17impl GitService {
18 pub async fn signature_verify_commit(
19 &self,
20 space_name: String,
21 repo_name: String,
22 sha: String,
23 ) -> Result<VerifyResultDto, GitError> {
24 let ctx = self.context(space_name, repo_name).await?;
25 let out = Command::new("git")
26 .arg("-C")
27 .arg(ctx.repo_dir())
28 .args(["verify-commit", "--raw", &sha])
29 .output()
30 .map_err(|e| GitError::Internal(format!("verify-commit: {e}")))?;
31 let stdout = String::from_utf8_lossy(&out.stdout);
32 let mut valid = false;
33 let mut signer: Option<String> = None;
34 let mut key_id: Option<String> = None;
35 for line in stdout.lines() {
36 if line.starts_with("good ") || line.starts_with("valid ") {
37 valid = true;
38 }
39 if let Some(idx) = line.find("signer \"") {
40 let rest = &line[idx + 8..];
41 if let Some(end) = rest.find('"') {
42 signer = Some(rest[..end].to_string());
43 }
44 }
45 if let Some(idx) = line.find("key ") {
46 key_id = Some(line[idx + 4..].trim().to_string());
47 }
48 }
49 let error_message = if !out.status.success() {
50 Some(String::from_utf8_lossy(&out.stderr).trim().to_string())
51 } else {
52 None
53 };
54 Ok(VerifyResultDto {
55 signed: valid || key_id.is_some() || signer.is_some(),
56 valid,
57 key_id,
58 signer,
59 error_message,
60 })
61 }
62
63 pub async fn signature_verify_tag(
64 &self,
65 space_name: String,
66 repo_name: String,
67 name: String,
68 ) -> Result<VerifyResultDto, GitError> {
69 let ctx = self.context(space_name, repo_name).await?;
70 let res = self
71 .baby(&ctx)
72 .verify_tag_signature(&name)
73 .await
74 .map_err(map_baby)?;
75 Ok(VerifyResultDto {
76 signed: res.valid || res.signer.is_some(),
77 valid: res.valid,
78 key_id: res.fingerprint,
79 signer: res.signer,
80 error_message: res.error_message,
81 })
82 }
83}
Added lib/git/src/service/submodules.rs View lib/git/src/service/submodules.rs
@@ -0,0 +1,72 @@
1use std::collections::HashMap;
2
3use gitbaby::commit::types::TreeEntryMode;
4use serde::Serialize;
5
6use crate::service::GitService;
7use crate::share::errors::{GitError, map_baby};
8
9#[derive(Debug, Clone, Serialize)]
10pub struct SubmoduleDto {
11 pub path: String,
12 pub url: String,
13 pub branch: Option<String>,
14 pub commit_sha: Option<String>,
15}
16
17impl GitService {
18 pub async fn submodule_list(
19 &self,
20 space_name: String,
21 repo_name: String,
22 revision: String,
23 ) -> Result<Vec<SubmoduleDto>, GitError> {
24 let ctx = self.context(space_name, repo_name).await?;
25 let baby = self.baby(&ctx);
26 let entries = baby
27 .get_tree_entries(&revision, std::path::Path::new(""))
28 .await
29 .map_err(map_baby)?;
30
31 let mut meta: HashMap<String, (String, String)> = HashMap::new();
32 if let Ok(submodules) = baby.list_submodules(&revision).await {
33 for s in submodules {
34 meta.insert(s.path.clone(), (s.url.clone(), s.branch.clone()));
35 }
36 }
37
38 let mut out = Vec::new();
39 for e in entries {
40 if e.mode == TreeEntryMode::Submodule {
41 let (url, branch) = meta.get(&e.name).cloned().unwrap_or_default();
42 out.push(SubmoduleDto {
43 path: e.name.clone(),
44 url,
45 branch: Some(branch).filter(|b| !b.is_empty()),
46 commit_sha: Some(e.oid.to_string()),
47 });
48 }
49 }
50 Ok(out)
51 }
52
53 pub async fn submodule_resolve(
54 &self,
55 space_name: String,
56 repo_name: String,
57 revision: String,
58 path: String,
59 ) -> Result<String, GitError> {
60 let ctx = self.context(space_name, repo_name).await?;
61 let baby = self.baby(&ctx);
62 let entries = baby
63 .get_tree_entries(&revision, std::path::Path::new(""))
64 .await
65 .map_err(map_baby)?;
66 entries
67 .into_iter()
68 .find(|e| e.name == path && e.mode == TreeEntryMode::Submodule)
69 .map(|e| e.oid.to_string())
70 .ok_or(GitError::NotFound)
71 }
72}
Added lib/git/src/service/tags.rs View lib/git/src/service/tags.rs
@@ -0,0 +1,310 @@
1use std::collections::HashMap;
2use std::path::Path;
3
4use gitbaby::command::cmd::Cmd;
5use gitbaby::command::env::Env;
6use gitbaby::tags::{CreateTagOptions, ListTagsOptions, TagVerifyResult};
7use models::git::git_tags;
8use models::git::git_tags::TagType;
9use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
10use serde::{Deserialize, Serialize};
11use time::OffsetDateTime;
12
13use crate::context::facade::GitContext;
14use crate::service::GitService;
15use crate::share::errors::{GitError, map_baby};
16use crate::share::policy::load_tag_policy;
17
18const CALLER: &str = "gitdata::git:tags";
19
20#[derive(Debug, Clone, Serialize)]
21pub struct TagDto {
22 pub name: String,
23 pub tag_type: TagType,
24 pub target_sha: String,
25 pub message: Option<String>,
26 pub tagger_name: Option<String>,
27 pub tagger_email: Option<String>,
28 pub created_at: Option<OffsetDateTime>,
29}
30
31#[derive(Debug, Clone, Deserialize)]
32pub struct CreateTagCmd {
33 pub name: String,
34 pub target: String,
35 #[serde(default)]
36 pub message: Option<String>,
37 #[serde(default)]
38 pub annotated: bool,
39}
40
41#[derive(Debug, Clone)]
42struct RawTagMeta {
43 target_sha: String,
44 is_annotated: bool,
45 tagger_name: Option<String>,
46 tagger_email: Option<String>,
47 message: Option<String>,
48}
49
50fn parse_tagger(line: &str) -> Option<(String, String)> {
51 let rest = line.strip_prefix("tagger ")?;
52 let (head, _) = rest.split_once('>')?;
53 let (name, email) = head.split_once(" <")?;
54 Some((name.trim().to_string(), email.trim().to_string()))
55}
56
57fn parse_cat_file(output: &str, ref_oid: &str) -> Option<RawTagMeta> {
58 let mut lines = output.lines();
59 let first = lines.next().unwrap_or("");
60 let Some(target) = first.strip_prefix("object ") else {
61 return Some(RawTagMeta {
62 target_sha: ref_oid.to_string(),
63 is_annotated: false,
64 tagger_name: None,
65 tagger_email: None,
66 message: None,
67 });
68 };
69 let mut tagger_name = None;
70 let mut tagger_email = None;
71 for line in lines.by_ref() {
72 if line.is_empty() {
73 break;
74 }
75 if let Some((n, e)) = parse_tagger(line) {
76 tagger_name = Some(n);
77 tagger_email = Some(e);
78 }
79 }
80 let rest: Vec<&str> = lines.collect();
81 let message = match rest.join("\n").trim() {
82 "" => None,
83 m => Some(m.to_string()),
84 };
85 Some(RawTagMeta {
86 target_sha: target.trim().to_string(),
87 is_annotated: true,
88 tagger_name,
89 tagger_email,
90 message,
91 })
92}
93
94async fn run_git(dir: &Path, args: &[&str]) -> Result<(bool, String, String), GitError> {
95 let mut cmd = Cmd::new(
96 CALLER,
97 "git",
98 args.iter().map(|s| s.to_string()).collect(),
99 Vec::new(),
100 dir.to_path_buf(),
101 Env::new(),
102 None,
103 );
104 let output = cmd
105 .run()
106 .await
107 .map_err(|e| GitError::Internal(format!("git {args:?} failed: {e}")))?;
108 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
109 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
110 Ok((output.status.success(), stdout, stderr))
111}
112
113async fn read_tag_meta(ctx: &GitContext, name: &str) -> Result<RawTagMeta, GitError> {
114 let dir = ctx.repo_dir();
115 let (ok, stdout, stderr) = run_git(&dir, &["show-ref", "--tags", "--", name]).await?;
116 if !ok {
117 let s = stderr.trim();
118 if s.contains("not found") || stdout.is_empty() {
119 return Err(GitError::NotFound);
120 }
121 return Err(GitError::Internal(format!("show-ref {name} failed: {s}")));
122 }
123 let oid = stdout
124 .split_whitespace()
125 .next()
126 .ok_or(GitError::NotFound)?
127 .to_string();
128 let (ok, stdout, stderr) = run_git(&dir, &["cat-file", "-p", &oid]).await?;
129 if !ok {
130 return Err(GitError::Internal(format!(
131 "cat-file {oid} failed: {}",
132 stderr.trim()
133 )));
134 }
135 parse_cat_file(&stdout, &oid)
136 .ok_or_else(|| GitError::Internal(format!("parse tag object {oid} for `{name}` failed")))
137}
138
139impl GitService {
140 pub async fn create_tag(
141 &self,
142 space_name: String,
143 repo_name: String,
144 cmd: CreateTagCmd,
145 ) -> Result<TagDto, GitError> {
146 if cmd.name.trim().is_empty()
147 || cmd.name.contains(['\0', '\n', '\r', ' '])
148 || cmd.target.trim().is_empty()
149 {
150 return Err(GitError::InvalidArgument(
151 "invalid tag name or target".to_string(),
152 ));
153 }
154 let ctx = self.context(space_name, repo_name).await?;
155 let tag_policy = load_tag_policy(&self.state.database, ctx.model.id).await?;
156 if tag_policy.is_match(&cmd.name) {
157 return Err(GitError::Forbidden);
158 }
159 let baby = self.baby(&ctx);
160 let opts = CreateTagOptions {
161 name: cmd.name.clone(),
162 target: cmd.target.clone(),
163 message: if cmd.annotated {
164 cmd.message.clone()
165 } else {
166 None
167 },
168 };
169 if cmd.annotated {
170 baby.create_annotated_tag(opts).await.map_err(map_baby)?;
171 } else {
172 baby.create_lightweight_tag(opts).await.map_err(map_baby)?;
173 }
174
175 let meta = read_tag_meta(&ctx, &cmd.name).await?;
176 let row = git_tags::ActiveModel {
177 id: Set(uuid::Uuid::now_v7()),
178 repo_id: Set(ctx.model.id),
179 name: Set(cmd.name.clone()),
180 tag_type: Set(if meta.is_annotated {
181 TagType::Annotated
182 } else {
183 TagType::Lightweight
184 }),
185 commit_sha: Set(meta.target_sha.clone()),
186 message: Set(meta.message.clone()),
187 tagger_name: Set(meta.tagger_name.clone()),
188 tagger_email: Set(meta.tagger_email.clone()),
189 created_at: Set(OffsetDateTime::now_utc()),
190 };
191 let inserted = row.insert(self.db_write()).await?;
192 self.sync_repo_stats(&ctx).await?;
193 Ok(TagDto {
194 name: inserted.name,
195 tag_type: inserted.tag_type,
196 target_sha: inserted.commit_sha,
197 message: inserted.message,
198 tagger_name: inserted.tagger_name,
199 tagger_email: inserted.tagger_email,
200 created_at: Some(inserted.created_at),
201 })
202 }
203
204 pub async fn list_tags(
205 &self,
206 space_name: String,
207 repo_name: String,
208 ) -> Result<Vec<TagDto>, GitError> {
209 let ctx = self.context(space_name, repo_name).await?;
210 let baby = self.baby(&ctx);
211 let tags = baby
212 .list_tags(ListTagsOptions {
213 patterns: vec!["refs/tags/*".to_string()],
214 })
215 .await
216 .map_err(map_baby)?;
217
218 let rows = git_tags::Entity::find()
219 .filter(git_tags::Column::RepoId.eq(ctx.model.id))
220 .order_by_asc(git_tags::Column::CreatedAt)
221 .all(self.db_read())
222 .await?;
223 let db: HashMap<String, git_tags::Model> =
224 rows.into_iter().map(|r| (r.name.clone(), r)).collect();
225
226 let mut out = Vec::with_capacity(tags.len());
227 for t in &tags {
228 let meta = read_tag_meta(&ctx, &t.name).await?;
229 let created_at = db.get(&t.name).map(|r| r.created_at);
230 out.push(TagDto {
231 name: t.name.clone(),
232 tag_type: if meta.is_annotated {
233 TagType::Annotated
234 } else {
235 TagType::Lightweight
236 },
237 target_sha: meta.target_sha,
238 message: meta.message,
239 tagger_name: meta.tagger_name,
240 tagger_email: meta.tagger_email,
241 created_at,
242 });
243 }
244 Ok(out)
245 }
246
247 pub async fn get_tag(
248 &self,
249 space_name: String,
250 repo_name: String,
251 name: String,
252 ) -> Result<TagDto, GitError> {
253 if name.trim().is_empty() || name.contains(['\0', '\n', '\r', ' ']) {
254 return Err(GitError::InvalidArgument("invalid tag name".to_string()));
255 }
256 let ctx = self.context(space_name, repo_name).await?;
257 let meta = read_tag_meta(&ctx, &name).await?;
258 let row = git_tags::Entity::find()
259 .filter(git_tags::Column::RepoId.eq(ctx.model.id))
260 .filter(git_tags::Column::Name.eq(&name))
261 .one(self.db_read())
262 .await?;
263 Ok(TagDto {
264 name,
265 tag_type: if meta.is_annotated {
266 TagType::Annotated
267 } else {
268 TagType::Lightweight
269 },
270 target_sha: meta.target_sha,
271 message: meta.message,
272 tagger_name: meta.tagger_name,
273 tagger_email: meta.tagger_email,
274 created_at: row.map(|r| r.created_at),
275 })
276 }
277
278 pub async fn verify_tag(
279 &self,
280 space_name: String,
281 repo_name: String,
282 name: String,
283 ) -> Result<TagVerifyResult, GitError> {
284 let ctx = self.context(space_name, repo_name).await?;
285 let baby = self.baby(&ctx);
286 baby.verify_tag_signature(&name).await.map_err(map_baby)
287 }
288
289 pub async fn delete_tag(
290 &self,
291 space_name: String,
292 repo_name: String,
293 name: String,
294 ) -> Result<(), GitError> {
295 let ctx = self.context(space_name, repo_name).await?;
296 let tag_policy = load_tag_policy(&self.state.database, ctx.model.id).await?;
297 if tag_policy.is_match(&name) {
298 return Err(GitError::Forbidden);
299 }
300 let baby = self.baby(&ctx);
301 baby.delete_tag(&name).await.map_err(map_baby)?;
302 git_tags::Entity::delete_many()
303 .filter(git_tags::Column::RepoId.eq(ctx.model.id))
304 .filter(git_tags::Column::Name.eq(&name))
305 .exec(self.db_write())
306 .await?;
307 self.sync_repo_stats(&ctx).await?;
308 Ok(())
309 }
310}
Added lib/git/src/service/trees.rs View lib/git/src/service/trees.rs
@@ -0,0 +1,166 @@
1use std::path::PathBuf;
2
3use gitbaby::commit::types::{TreeEntry, TreeEntryMode};
4use gitbaby::tree::types::WalkTreeOptions;
5
6use crate::service::GitService;
7use crate::share::errors::{GitError, map_baby};
8use crate::share::validate::validate_revision;
9
10#[derive(Debug, Clone, serde::Serialize)]
11pub struct PathLastUpdated {
12 pub path: String,
13 pub object_type: i8,
14 pub last_updated: i64,
15 pub commit_id: String,
16}
17
18fn entry_full_path(entry: &TreeEntry) -> String {
19 let mut s = entry.name.clone();
20 if entry.mode == TreeEntryMode::Tree {
21 s.push('/');
22 }
23 s
24}
25
26impl GitService {
27 pub async fn tree_read(
28 &self,
29 space_name: String,
30 repo_name: String,
31 revision: String,
32 path: PathBuf,
33 ) -> Result<Vec<TreeEntry>, GitError> {
34 validate_revision(&revision)?;
35 let ctx = self.context(space_name, repo_name).await?;
36 let baby = self.baby(&ctx);
37 baby.get_tree_entries(&revision, &path)
38 .await
39 .map_err(map_baby)
40 }
41
42 pub async fn tree_resolve_path(
43 &self,
44 space_name: String,
45 repo_name: String,
46 revision: String,
47 path: String,
48 ) -> Result<TreeEntry, GitError> {
49 validate_revision(&revision)?;
50 let ctx = self.context(space_name, repo_name).await?;
51 let baby = self.baby(&ctx);
52 let entries = baby
53 .get_tree_entries(&revision, std::path::Path::new(""))
54 .await
55 .map_err(map_baby)?;
56 let name = path.trim().trim_start_matches("./").to_string();
57 entries
58 .into_iter()
59 .find(|e| e.name == name)
60 .ok_or(GitError::NotFound)
61 }
62
63 pub async fn tree_walk(
64 &self,
65 space_name: String,
66 repo_name: String,
67 revision: String,
68 recurse: bool,
69 show_trees: bool,
70 ) -> Result<Vec<TreeEntry>, GitError> {
71 validate_revision(&revision)?;
72 let ctx = self.context(space_name, repo_name).await?;
73 let baby = self.baby(&ctx);
74 baby.walk_tree(
75 &revision,
76 std::path::Path::new(""),
77 WalkTreeOptions {
78 recurse,
79 show_trees,
80 },
81 )
82 .await
83 .map_err(map_baby)
84 }
85
86 pub async fn tree_follow(
87 &self,
88 space_name: String,
89 repo_name: String,
90 revision: String,
91 path: String,
92 ) -> Result<(TreeEntry, Option<String>, Option<TreeEntry>), GitError> {
93 validate_revision(&revision)?;
94 let entry = self
95 .tree_resolve_path(
96 space_name.clone(),
97 repo_name.clone(),
98 revision.clone(),
99 path,
100 )
101 .await?;
102 if entry.mode != TreeEntryMode::Symlink {
103 return Ok((entry, None, None));
104 }
105 let ctx = self.context(space_name, repo_name).await?;
106 let baby = self.baby(&ctx);
107 let blob = baby
108 .get_blob(
109 entry.oid,
110 gitbaby::blob::types::GetBlobOptions { limit: Some(8192) },
111 )
112 .await
113 .map_err(map_baby)?;
114 let target = String::from_utf8_lossy(&blob.data).trim().to_string();
115 let mut target_path = PathBuf::from(&target);
116 if target_path.is_relative() {
117 let parent = std::path::Path::new(&entry.name)
118 .parent()
119 .map(|p| p.to_path_buf())
120 .unwrap_or_default();
121 target_path = parent.join(target_path);
122 }
123 let target_entry = baby
124 .get_tree_entries(&revision, &target_path)
125 .await
126 .ok()
127 .and_then(|entries| entries.into_iter().next())
128 .filter(|_| !target.is_empty());
129 Ok((entry, Some(target), target_entry))
130 }
131
132 pub async fn tree_last_updated(
133 &self,
134 space_name: String,
135 repo_name: String,
136 revision: String,
137 ) -> Result<Vec<PathLastUpdated>, GitError> {
138 validate_revision(&revision)?;
139 let ctx = self.context(space_name, repo_name).await?;
140 let baby = self.baby(&ctx);
141 let rows = baby
142 .tree_entries_with_latest(&revision, None)
143 .await
144 .map_err(map_baby)?;
145 Ok(rows
146 .into_iter()
147 .map(|row| PathLastUpdated {
148 path: entry_full_path(&row.entry),
149 object_type: mode_to_i8(row.entry.mode),
150 last_updated: row.latest.author.time.unix_timestamp(),
151 commit_id: row.latest.id.to_string(),
152 })
153 .collect())
154 }
155}
156
157fn mode_to_i8(mode: TreeEntryMode) -> i8 {
158 match mode {
159 TreeEntryMode::Blob => 0,
160 TreeEntryMode::Exec => 1,
161 TreeEntryMode::Symlink => 2,
162 TreeEntryMode::Tree => 3,
163 TreeEntryMode::Submodule => 4,
164 TreeEntryMode::Commit => 5,
165 }
166}
Added lib/git/src/share/errors.rs View lib/git/src/share/errors.rs
@@ -0,0 +1,91 @@
1use gitbaby::error::BabyError;
2
3#[derive(Debug, thiserror::Error)]
4pub enum GitError {
5 #[error("cache error: {0}")]
6 Cache(#[from] cache::CacheError),
7 #[error("invalid argument: {0}")]
8 InvalidArgument(String),
9 #[error("conflict: {0}")]
10 Conflict(String),
11 #[error("resource not found")]
12 NotFound,
13 #[error("forbidden")]
14 Forbidden,
15 #[error("git error: {0}")]
16 Git(#[from] BabyError),
17 #[error("internal error: {0}")]
18 Internal(String),
19}
20
21impl From<sea_orm::DbErr> for GitError {
22 fn from(err: sea_orm::DbErr) -> Self {
23 if is_unique_violation(&err) {
24 Self::Conflict("resource already exists".to_string())
25 } else {
26 Self::Internal(format!("database error: {err}"))
27 }
28 }
29}
30
31impl From<sea_orm::TransactionError<sea_orm::DbErr>> for GitError {
32 fn from(err: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
33 match &err {
34 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
35 Self::Conflict("resource already exists".to_string())
36 }
37 _ => Self::Internal(format!("database transaction error: {err}")),
38 }
39 }
40}
41
42impl From<sea_orm::TransactionError<GitError>> for GitError {
43 fn from(err: sea_orm::TransactionError<GitError>) -> Self {
44 match err {
45 sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
46 sea_orm::TransactionError::Transaction(git_err) => git_err,
47 }
48 }
49}
50
51impl From<std::io::Error> for GitError {
52 fn from(err: std::io::Error) -> Self {
53 Self::Internal(format!("io error: {err}"))
54 }
55}
56
57fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
58 let msg = err.to_string().to_lowercase();
59 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
60}
61
62pub fn map_baby(err: BabyError) -> GitError {
63 use BabyError::*;
64 match err {
65 BranchNotFound(_)
66 | TagNotFound(_)
67 | RefNotFound(_)
68 | RevisionNotFound(_)
69 | BlobNotFound { .. }
70 | RemoteNotFound(_)
71 | PathNotFound { .. }
72 | MergeBaseNotFound(_) => GitError::NotFound,
73 BranchAlreadyExists(name) | TagAlreadyExists(name) | RemoteAlreadyExists(name) => {
74 GitError::Conflict(format!("`{name}` already exists"))
75 }
76 RefUpdateRejected(reason) => GitError::Conflict(reason),
77 RevisionEmpty
78 | RevisionInvalid(_)
79 | InvalidRefName(_)
80 | InvalidBranchName(_)
81 | InvalidTagName(_)
82 | InvalidRemoteUrl(_)
83 | InvalidGitArg(_)
84 | InvalidRange
85 | PathEscapesRepository
86 | InvalidTimestamp(_)
87 | AmbiguousRef { .. }
88 | PayloadTooLarge { .. } => GitError::InvalidArgument(err.to_string()),
89 other => GitError::Git(other),
90 }
91}
Added lib/git/src/share/identity.rs View lib/git/src/share/identity.rs
@@ -0,0 +1,37 @@
1use models::users::user;
2use models::users::user_emails;
3use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
4use uuid::Uuid;
5
6use crate::share::errors::GitError;
7
8#[derive(Clone, Debug)]
9pub struct GitIdentity {
10 pub name: String,
11 pub email: String,
12}
13
14pub async fn load_identity(db: &export::AppDatabase, uid: Uuid) -> Result<GitIdentity, GitError> {
15 let u = user::Entity::find_by_id(uid)
16 .one(db.read())
17 .await?
18 .ok_or(GitError::NotFound)?;
19 let email = user_emails::Entity::find()
20 .filter(user_emails::Column::UserId.eq(uid))
21 .filter(user_emails::Column::IsPrimary.eq(true))
22 .filter(user_emails::Column::IsActivated.eq(true))
23 .filter(user_emails::Column::DeletedAt.is_null())
24 .one(db.read())
25 .await?
26 .ok_or_else(|| GitError::InvalidArgument("no active primary email".to_string()))?;
27
28 let name = if u.display_name.trim().is_empty() {
29 u.username.clone()
30 } else {
31 u.display_name.clone()
32 };
33 Ok(GitIdentity {
34 name,
35 email: email.lower_email,
36 })
37}
Added lib/git/src/share/mod.rs View lib/git/src/share/mod.rs
@@ -0,0 +1,4 @@
1pub mod errors;
2pub mod identity;
3pub mod policy;
4pub mod validate;
Added lib/git/src/share/policy.rs View lib/git/src/share/policy.rs
@@ -0,0 +1,55 @@
1use globset::{Glob, GlobMatcher};
2use models::repo::repo_branch_protection_policy;
3use models::repo::repo_tag_protection_policy;
4use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
5use uuid::Uuid;
6
7use crate::share::errors::GitError;
8
9#[derive(Debug, Default)]
10pub struct BranchPolicy {
11 patterns: Vec<GlobMatcher>,
12}
13
14impl BranchPolicy {
15 pub fn from_patterns(patterns: impl IntoIterator<Item = String>) -> Result<Self, GitError> {
16 let mut out = Vec::new();
17 for p in patterns {
18 let glob = Glob::new(&p).map_err(|e| {
19 GitError::InvalidArgument(format!("invalid branch pattern `{p}`: {e}"))
20 })?;
21 out.push(glob.compile_matcher());
22 }
23 Ok(Self { patterns: out })
24 }
25
26 pub fn is_empty(&self) -> bool {
27 self.patterns.is_empty()
28 }
29
30 pub fn is_match(&self, name: &str) -> bool {
31 self.patterns.iter().any(|m| m.is_match(name))
32 }
33}
34
35pub async fn load_branch_policy(
36 db: &export::AppDatabase,
37 repo_id: Uuid,
38) -> Result<BranchPolicy, GitError> {
39 let rows = repo_branch_protection_policy::Entity::find()
40 .filter(repo_branch_protection_policy::Column::RepoId.eq(repo_id))
41 .all(db.read())
42 .await?;
43 BranchPolicy::from_patterns(rows.into_iter().map(|r| r.branch_pattern))
44}
45
46pub async fn load_tag_policy(
47 db: &export::AppDatabase,
48 repo_id: Uuid,
49) -> Result<BranchPolicy, GitError> {
50 let rows = repo_tag_protection_policy::Entity::find()
51 .filter(repo_tag_protection_policy::Column::RepoId.eq(repo_id))
52 .all(db.read())
53 .await?;
54 BranchPolicy::from_patterns(rows.into_iter().map(|r| r.name_pattern))
55}
Added lib/git/src/share/validate.rs View lib/git/src/share/validate.rs
@@ -0,0 +1,202 @@
1use crate::share::errors::GitError;
2
3pub const MAX_REPO_NAME: usize = 255;
4pub const MAX_BRANCH_NAME: usize = 250;
5
6pub fn validate_repo_name(name: &str) -> Result<String, GitError> {
7 let n = name.trim();
8 if n.is_empty() || n.chars().count() > MAX_REPO_NAME {
9 return Err(GitError::InvalidArgument(
10 "repo name is empty or too long".to_string(),
11 ));
12 }
13 if n == "." || n == ".." {
14 return Err(GitError::InvalidArgument(format!(
15 "invalid repo name `{n}`"
16 )));
17 }
18 if n.starts_with('.') || n.ends_with('.') || n.starts_with('-') || n.ends_with('-') {
19 return Err(GitError::InvalidArgument(format!(
20 "repo name `{n}` must not start or end with `.` or `-`"
21 )));
22 }
23 if !n
24 .chars()
25 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
26 {
27 return Err(GitError::InvalidArgument(format!(
28 "repo name `{n}` contains invalid characters"
29 )));
30 }
31 Ok(n.to_string())
32}
33
34pub fn validate_branch_name(name: &str) -> Result<String, GitError> {
35 let n = name.trim();
36 if n.is_empty() || n.chars().count() > MAX_BRANCH_NAME {
37 return Err(GitError::InvalidArgument(
38 "branch name is empty or too long".to_string(),
39 ));
40 }
41 if n.starts_with('-')
42 || n.ends_with('/')
43 || n.ends_with(".lock")
44 || n.contains("..")
45 || n.contains("@{")
46 || n.contains("//")
47 || n.contains('\\')
48 {
49 return Err(GitError::InvalidArgument(format!(
50 "invalid branch name `{n}`"
51 )));
52 }
53 if n.chars().any(|c| c.is_whitespace() || c.is_control()) {
54 return Err(GitError::InvalidArgument(format!(
55 "invalid branch name `{n}`"
56 )));
57 }
58 Ok(n.to_string())
59}
60
61pub fn validate_revision(rev: &str) -> Result<(), GitError> {
62 if rev.trim().is_empty() {
63 return Err(GitError::InvalidArgument("empty revision".to_string()));
64 }
65 if rev.starts_with('-') {
66 return Err(GitError::InvalidArgument(format!(
67 "revision must not start with `-`: `{rev}`"
68 )));
69 }
70 if rev.contains(['\0', '\n', '\r']) {
71 return Err(GitError::InvalidArgument(format!(
72 "revision contains control characters: `{rev}`"
73 )));
74 }
75 Ok(())
76}
77
78pub fn validate_qualified_ref(name: &str) -> Result<(), GitError> {
79 let n = name.trim();
80 if !n.starts_with("refs/") {
81 return Err(GitError::InvalidArgument(format!(
82 "qualified ref must start with `refs/`: `{n}`"
83 )));
84 }
85 if n.ends_with('/')
86 || n.ends_with(".lock")
87 || n.contains("..")
88 || n.contains("@{")
89 || n.contains("//")
90 || n.contains('\\')
91 || n.chars().any(|c| c.is_whitespace() || c.is_control())
92 {
93 return Err(GitError::InvalidArgument(format!("invalid ref name `{n}`")));
94 }
95 Ok(())
96}
97
98const ALLOWED_SCHEMES: &[&str] = &["https", "http", "ssh", "git"];
99
100pub fn validate_remote_url(url: &str) -> Result<(), GitError> {
101 let u = url.trim();
102 if u.is_empty() || u.starts_with('-') {
103 return Err(GitError::InvalidArgument("invalid remote url".to_string()));
104 }
105 if u.contains('\0') || u.contains('\n') || u.contains('\r') {
106 return Err(GitError::InvalidArgument(
107 "remote url contains control characters".to_string(),
108 ));
109 }
110 if !u.contains("://") {
111 if u.contains('@') {
112 return Err(GitError::InvalidArgument(format!(
113 "remote url `{u}` is not supported"
114 )));
115 }
116 return Ok(());
117 }
118 let (scheme, rest) = u
119 .split_once("://")
120 .ok_or_else(|| GitError::InvalidArgument("invalid remote url".to_string()))?;
121 let scheme = scheme.to_ascii_lowercase();
122 if !ALLOWED_SCHEMES.contains(&scheme.as_str()) {
123 return Err(GitError::InvalidArgument(format!(
124 "remote url scheme `{scheme}` is not allowed"
125 )));
126 }
127 let hostport = rest.split('/').next().unwrap_or_default();
128 let userinfo = hostport.rsplit_once('@').map(|(u, _)| u);
129 if let Some(info) = userinfo
130 && info.contains(':')
131 {
132 return Err(GitError::InvalidArgument(
133 "remote url userinfo with password is not allowed".to_string(),
134 ));
135 }
136 let mut host = hostport
137 .rsplit('@')
138 .next()
139 .unwrap_or_default()
140 .trim()
141 .trim_matches(['[', ']']);
142 if let Some(idx) = host.rfind(':') {
143 let (h, p) = host.split_at(idx);
144 let port = &p[1..];
145 if host.matches(':').count() == 1
146 && !port.is_empty()
147 && port.chars().all(|c| c.is_ascii_digit())
148 {
149 host = h;
150 }
151 }
152 let host = host.to_ascii_lowercase();
153 if host.is_empty() || is_forbidden_host(&host) {
154 return Err(GitError::InvalidArgument(format!(
155 "remote url host `{host}` is not allowed"
156 )));
157 }
158 Ok(())
159}
160
161fn is_forbidden_host(host: &str) -> bool {
162 if host == "localhost" || host.ends_with(".localhost") {
163 return true;
164 }
165 if host.ends_with(".local") || host.ends_with(".internal") {
166 return true;
167 }
168 if let Some(segments) = parse_ipv4(host) {
169 if segments[0] == 127
170 || segments[0] == 10
171 || segments[0] == 0
172 || segments[0] == 169 && segments[1] == 254
173 || segments[0] == 192 && segments[1] == 168
174 || segments[0] == 172 && (16..=31).contains(&segments[1])
175 {
176 return true;
177 }
178 } else if host == "::1"
179 || host.starts_with("fe80:")
180 || host.starts_with("fc")
181 || host.starts_with("fd")
182 {
183 return true;
184 }
185 false
186}
187
188fn parse_ipv4(host: &str) -> Option<[u8; 4]> {
189 let parts: Vec<&str> = host.split('.').collect();
190 if parts.len() != 4 {
191 return None;
192 }
193 let mut out = [0u8; 4];
194 for (i, p) in parts.iter().enumerate() {
195 let n: u16 = p.parse().ok()?;
196 if n > 255 {
197 return None;
198 }
199 out[i] = n as u8;
200 }
201 Some(out)
202}
Added lib/git/src/smart/mod.rs View lib/git/src/smart/mod.rs
@@ -0,0 +1 @@
1
Added lib/git/src/ssh/mod.rs View lib/git/src/ssh/mod.rs
@@ -0,0 +1 @@
1
Added lib/git/src/sync/branches.rs View lib/git/src/sync/branches.rs
@@ -0,0 +1,146 @@
1use std::collections::HashMap;
2
3use gitbaby::GitBaby;
4use gitbaby::branch::ListBranchesOptions;
5use models::git::git_branches;
6use models::repo::repos;
7use sea_orm::{
8 ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, QueryFilter,
9 Set,
10};
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14use crate::sync::error::{SyncError, SyncResult};
15
16const PREFERRED_DEFAULT_BRANCHES: &[&str] = &["main", "master", "trunk"];
17
18#[derive(Debug, Clone)]
19pub(crate) struct BranchTip {
20 pub name: String,
21 pub target_sha: String,
22}
23
24pub async fn sync_branches(
25 baby: &GitBaby,
26 txn: &DatabaseTransaction,
27 repo: &repos::Model,
28) -> SyncResult<usize> {
29 let collected = collect_branch_tips(baby).await?;
30
31 let existing = git_branches::Entity::find()
32 .filter(git_branches::Column::RepoId.eq(repo.id))
33 .all(txn)
34 .await
35 .map_err(SyncError::Db)?;
36
37 let mut existing_by_name: HashMap<String, git_branches::Model> = existing
38 .into_iter()
39 .filter(|b| !b.is_deleted)
40 .map(|b| (b.name.clone(), b))
41 .collect();
42
43 let mut stale: std::collections::HashSet<String> = existing_by_name.keys().cloned().collect();
44
45 for tip in &collected {
46 stale.remove(&tip.name);
47 match existing_by_name.get(&tip.name) {
48 Some(row) if row.commit_sha != tip.target_sha => {
49 let mut am = row.clone().into_active_model();
50 am.commit_sha = Set(tip.target_sha.clone());
51 am.updated_at = Set(OffsetDateTime::now_utc());
52 am.update(txn).await.map_err(SyncError::Db)?;
53 }
54 Some(_) => {}
55 None => {
56 let now = OffsetDateTime::now_utc();
57 git_branches::ActiveModel {
58 id: Set(Uuid::now_v7()),
59 repo_id: Set(repo.id),
60 name: Set(tip.name.clone()),
61 commit_sha: Set(tip.target_sha.clone()),
62 is_deleted: Set(false),
63 is_protected: Set(false),
64 deleted_at: Set(None),
65 created_at: Set(now),
66 updated_at: Set(now),
67 }
68 .insert(txn)
69 .await
70 .map_err(SyncError::Db)?;
71 }
72 }
73 }
74
75 for name in stale {
76 if existing_by_name.get(&name).is_some_and(|r| r.is_protected) {
77 continue;
78 }
79 let Some(row) = existing_by_name.remove(&name) else {
80 continue;
81 };
82 let mut am = row.into_active_model();
83 am.is_deleted = Set(true);
84 am.deleted_at = Set(Some(OffsetDateTime::now_utc()));
85 am.updated_at = Set(OffsetDateTime::now_utc());
86 am.update(txn).await.map_err(SyncError::Db)?;
87 }
88
89 reconcile_default_branch(baby, txn, repo, &collected).await?;
90
91 Ok(collected.len())
92}
93
94pub(crate) async fn collect_branch_tips(baby: &GitBaby) -> SyncResult<Vec<BranchTip>> {
95 let branches = baby
96 .list_branches(ListBranchesOptions::default())
97 .await
98 .map_err(SyncError::Git)?;
99 Ok(branches
100 .into_iter()
101 .map(|b| BranchTip {
102 name: b.name,
103 target_sha: b.sha.to_string(),
104 })
105 .collect())
106}
107
108async fn reconcile_default_branch(
109 baby: &GitBaby,
110 txn: &DatabaseTransaction,
111 repo: &repos::Model,
112 collected: &[BranchTip],
113) -> SyncResult<()> {
114 if collected.is_empty() {
115 return Ok(());
116 }
117
118 let desired = if collected.iter().any(|b| b.name == repo.default_branch) {
119 repo.default_branch.clone()
120 } else {
121 PREFERRED_DEFAULT_BRANCHES
122 .iter()
123 .copied()
124 .find(|name| collected.iter().any(|b| b.name == *name))
125 .or_else(|| collected.first().map(|b| b.name.as_str()))
126 .unwrap_or(&repo.default_branch)
127 .to_string()
128 };
129
130 if desired != repo.default_branch {
131 let mut am = repo.clone().into_active_model();
132 am.default_branch = Set(desired.clone());
133 am.update(txn).await.map_err(SyncError::Db)?;
134
135 if let Err(e) = baby.set_default_branch(&desired).await {
136 tracing::warn!(
137 repo_id = %repo.id,
138 branch = %desired,
139 error = %e,
140 "failed to update on-disk HEAD symbolic-ref"
141 );
142 }
143 }
144
145 Ok(())
146}
Added lib/git/src/sync/commits.rs View lib/git/src/sync/commits.rs
@@ -0,0 +1,176 @@
1use std::collections::{HashMap, HashSet};
2
3use export::AppDatabase;
4use gitbaby::GitBaby;
5use gitbaby::commit::types::ListCommitsOptions;
6use models::git::git_commits;
7use models::users::user_emails;
8use sea_orm::{ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, QuerySelect, Set};
9use time::OffsetDateTime;
10use uuid::Uuid;
11
12use crate::sync::error::{SyncError, SyncResult};
13
14const BATCH_SIZE: usize = 100;
15
16#[derive(Debug, Clone)]
17pub(crate) struct CommitData {
18 pub sha: String,
19 pub author_name: String,
20 pub author_email: String,
21 pub committer_name: String,
22 pub committer_email: String,
23 pub message: String,
24 pub authored_at: OffsetDateTime,
25 pub committed_at: OffsetDateTime,
26}
27
28pub(crate) async fn collect_new_commits(
29 baby: &GitBaby,
30 existing_shas: &HashSet<String>,
31 max_total: usize,
32) -> SyncResult<Vec<CommitData>> {
33 let branches = baby
34 .list_branches(Default::default())
35 .await
36 .map_err(SyncError::Git)?;
37
38 let mut seen: HashSet<String> = existing_shas.clone();
39 let mut out: Vec<CommitData> = Vec::new();
40
41 for branch in branches {
42 if out.len() >= max_total {
43 break;
44 }
45 let remaining = max_total - out.len();
46 let opts = ListCommitsOptions {
47 revision: branch.name.clone(),
48 max_count: Some(remaining as u32),
49 ..Default::default()
50 };
51 let commits = match baby.list_commits(opts).await {
52 Ok(v) => v,
53 Err(e) => {
54 tracing::warn!(branch = %branch.name, error = %e, "list_commits failed; skip branch");
55 continue;
56 }
57 };
58 for info in commits {
59 let sha = info.id.to_string();
60 if !seen.insert(sha.clone()) {
61 continue;
62 }
63 out.push(CommitData {
64 sha,
65 author_name: info.author.name,
66 author_email: info.author.email,
67 committer_name: info.committer.name,
68 committer_email: info.committer.email,
69 message: info.message,
70 authored_at: info.author.time,
71 committed_at: info.committer.time,
72 });
73 if out.len() >= max_total {
74 break;
75 }
76 }
77 }
78
79 Ok(out)
80}
81
82pub(crate) async fn existing_commit_shas(
83 db: &AppDatabase,
84 repo_id: Uuid,
85) -> SyncResult<HashSet<String>> {
86 let rows = git_commits::Entity::find()
87 .filter(git_commits::Column::RepoId.eq(repo_id))
88 .select_only()
89 .column(git_commits::Column::Sha)
90 .into_tuple::<String>()
91 .all(db.read())
92 .await
93 .map_err(SyncError::Db)?;
94 Ok(rows.into_iter().collect())
95}
96
97pub(crate) async fn collect_new_commits_for_sync(
98 baby: &GitBaby,
99 db: &AppDatabase,
100 repo_id: Uuid,
101 max_total: usize,
102) -> SyncResult<Vec<CommitData>> {
103 let existing = existing_commit_shas(db, repo_id).await?;
104 collect_new_commits(baby, &existing, max_total).await
105}
106
107pub(crate) async fn sync_commits(
108 txn: &DatabaseTransaction,
109 repo_id: Uuid,
110 new_commits: &[CommitData],
111) -> SyncResult<usize> {
112 if new_commits.is_empty() {
113 return Ok(0);
114 }
115
116 let author_map = resolve_user_ids(txn, new_commits).await?;
117
118 let now = OffsetDateTime::now_utc();
119 for chunk in new_commits.chunks(BATCH_SIZE) {
120 let models = chunk
121 .iter()
122 .map(|c| {
123 let lower = c.author_email.to_lowercase();
124 git_commits::ActiveModel {
125 repo_id: Set(repo_id),
126 sha: Set(c.sha.clone()),
127 message: Set(c.message.clone()),
128 author_id: Set(author_map.get(&lower).copied()),
129 author_name: Set(c.author_name.clone()),
130 author_email: Set(c.author_email.clone()),
131 authored_at: Set(c.authored_at),
132 committer_name: Set(c.committer_name.clone()),
133 committer_email: Set(c.committer_email.clone()),
134 committed_at: Set(c.committed_at),
135 created_at: Set(now),
136 }
137 })
138 .collect::<Vec<_>>();
139 let mut oc = sea_orm::sea_query::OnConflict::new();
140 oc.do_nothing();
141 git_commits::Entity::insert_many(models)
142 .on_conflict(oc)
143 .exec(txn)
144 .await
145 .map_err(SyncError::Db)?;
146 }
147
148 Ok(new_commits.len())
149}
150
151async fn resolve_user_ids(
152 txn: &DatabaseTransaction,
153 commits: &[CommitData],
154) -> SyncResult<HashMap<String, Uuid>> {
155 let mut lower_emails: HashSet<String> = HashSet::new();
156 for c in commits {
157 lower_emails.insert(c.author_email.to_lowercase());
158 lower_emails.insert(c.committer_email.to_lowercase());
159 }
160 if lower_emails.is_empty() {
161 return Ok(HashMap::new());
162 }
163
164 let rows = user_emails::Entity::find()
165 .filter(
166 user_emails::Column::LowerEmail.is_in(lower_emails.iter().cloned().collect::<Vec<_>>()),
167 )
168 .all(txn)
169 .await
170 .map_err(SyncError::Db)?;
171
172 Ok(rows
173 .into_iter()
174 .map(|r| (r.lower_email, r.user_id))
175 .collect())
176}
Added lib/git/src/sync/error.rs View lib/git/src/sync/error.rs
@@ -0,0 +1,44 @@
1use std::io;
2
3use gitbaby::error::BabyError;
4use redis::RedisError;
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum SyncError {
9 #[error("repository sync lock is held by another worker")]
10 Locked,
11
12 #[error("database error: {0}")]
13 Db(#[from] sea_orm::DbErr),
14
15 #[error("redis error: {0}")]
16 Redis(#[from] RedisError),
17
18 #[error("git error: {0}")]
19 Git(#[from] BabyError),
20
21 #[error("i/o error: {0}")]
22 Io(#[from] io::Error),
23
24 #[error("operation timed out: {0}")]
25 Timeout(&'static str),
26
27 #[error("repository not found: {0}")]
28 NotFound(String),
29
30 #[error("invalid sync task: {0}")]
31 InvalidTask(String),
32
33 #[error("internal error: {0}")]
34 Internal(String),
35}
36
37pub type SyncResult<T> = Result<T, SyncError>;
38
39impl SyncError {
40 #[must_use]
41 pub fn is_locked(&self) -> bool {
42 matches!(self, SyncError::Locked)
43 }
44}
Added lib/git/src/sync/event.rs View lib/git/src/sync/event.rs
@@ -0,0 +1,106 @@
1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use crate::sync::queue::SyncQueue;
5use config::git::SyncSettings;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8pub struct PostReceiveRefUpdate {
9 pub name: String,
10 pub old_oid: String,
11 pub new_oid: String,
12}
13
14impl PostReceiveRefUpdate {
15 #[must_use]
16 pub fn new(
17 name: impl Into<String>,
18 old_oid: impl Into<String>,
19 new_oid: impl Into<String>,
20 ) -> Self {
21 Self {
22 name: name.into(),
23 old_oid: old_oid.into(),
24 new_oid: new_oid.into(),
25 }
26 }
27
28 #[must_use]
29 pub fn is_delete(&self) -> bool {
30 self.is_zero(self.new_oid.as_str())
31 }
32
33 #[must_use]
34 pub fn is_create(&self) -> bool {
35 self.is_zero(self.old_oid.as_str()) && !self.is_zero(self.new_oid.as_str())
36 }
37
38 fn is_zero(&self, oid: &str) -> bool {
39 oid.is_empty() || oid.chars().all(|c| c == '0')
40 }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44pub struct PostReceiveTask {
45 pub id: Uuid,
46 pub repo_id: Uuid,
47 pub channel_id: Uuid,
48 pub actor_id: Option<Uuid>,
49 pub refs: Vec<PostReceiveRefUpdate>,
50}
51
52impl PostReceiveTask {
53 #[must_use]
54 pub fn new(
55 repo_id: Uuid,
56 channel_id: Uuid,
57 actor_id: Option<Uuid>,
58 refs: Vec<PostReceiveRefUpdate>,
59 ) -> Self {
60 Self {
61 id: Uuid::now_v7(),
62 repo_id,
63 channel_id,
64 actor_id,
65 refs,
66 }
67 }
68}
69
70#[derive(Debug, thiserror::Error)]
71pub enum EnqueueError {
72 #[error("serialize task: {0}")]
73 Serialize(#[from] serde_json::Error),
74 #[error("queue error: {0}")]
75 Queue(String),
76}
77
78pub async fn enqueue_post_receive(
79 queue: &SyncQueue,
80 settings: &SyncSettings,
81 task: &PostReceiveTask,
82) -> Result<(), EnqueueError> {
83 let payload = serde_json::to_vec(task)?;
84 queue
85 .publish_raw(
86 &settings.queue_topic,
87 Some(task.repo_id.as_bytes()),
88 &payload,
89 )
90 .await
91 .map(|_| ())
92 .map_err(|e| EnqueueError::Queue(e.to_string()))
93}
94
95pub async fn enqueue_post_receive_dlq(
96 queue: &SyncQueue,
97 settings: &SyncSettings,
98 task: &PostReceiveTask,
99) -> Result<(), EnqueueError> {
100 let payload = serde_json::to_vec(task)?;
101 queue
102 .publish_raw(&settings.dlq_topic, Some(task.repo_id.as_bytes()), &payload)
103 .await
104 .map(|_| ())
105 .map_err(|e| EnqueueError::Queue(e.to_string()))
106}
Added lib/git/src/sync/fsck.rs View lib/git/src/sync/fsck.rs
@@ -0,0 +1,58 @@
1use std::path::Path;
2use std::process::Command;
3
4use crate::sync::error::{SyncError, SyncResult};
5
6pub async fn run_gc(repo_dir: &Path) {
7 let dir_str = repo_dir.display().to_string();
8 let dir = repo_dir.to_path_buf();
9 let result = tokio::task::spawn_blocking(move || {
10 let _ = run_git(&dir, &["gc", "--prune=now", "--quiet"]);
11 })
12 .await
13 .map_err(|e| SyncError::Internal(format!("join error: {e}")));
14
15 if let Err(e) = result {
16 tracing::warn!(repo_dir = %dir_str, error = %e, "git gc failed");
17 }
18}
19
20pub async fn run_fsck(repo_dir: &Path) {
21 let dir_str = repo_dir.display().to_string();
22 let dir = repo_dir.to_path_buf();
23 let result = tokio::task::spawn_blocking(move || run_git(&dir, &["fsck", "--no-progress"]))
24 .await
25 .map_err(|e| SyncError::Internal(format!("join error: {e}")));
26
27 match result {
28 Ok(Ok(stdout)) if stdout.contains("error") || stdout.contains("broken") => {
29 tracing::warn!(
30 repo_dir = %dir_str,
31 output = %stdout.trim(),
32 "fsck reported integrity issues"
33 );
34 }
35 Ok(_) => {}
36 Err(e) => {
37 tracing::warn!(repo_dir = %dir_str, error = %e, "fsck failed");
38 }
39 }
40}
41
42fn run_git(repo_dir: &Path, args: &[&str]) -> SyncResult<String> {
43 let output = Command::new("git")
44 .arg("-C")
45 .arg(repo_dir)
46 .args(args)
47 .output()
48 .map_err(|e| SyncError::Internal(format!("spawn git: {e}")))?;
49 if !output.status.success() {
50 return Err(SyncError::Internal(format!(
51 "git {} exited {}: {}",
52 args.join(" "),
53 output.status.code().unwrap_or(-1),
54 String::from_utf8_lossy(&output.stderr).trim()
55 )));
56 }
57 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
58}
Added lib/git/src/sync/languages.rs View lib/git/src/sync/languages.rs
@@ -0,0 +1,165 @@
1use std::time::Duration;
2
3use gitbaby::GitBaby;
4use gitbaby::commit::types::TreeEntryMode;
5use gitbaby::tree::types::WalkTreeOptions;
6use models::git::git_languages;
7use models::repo::repos;
8use sea_orm::{
9 ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, QueryFilter,
10 Set,
11};
12use time::OffsetDateTime;
13use uuid::Uuid;
14
15use crate::service::analysis::ext_to_language;
16use crate::sync::error::{SyncError, SyncResult};
17use config::git::SyncSettings;
18
19pub async fn sync_repo_languages(
20 baby: &GitBaby,
21 txn: &DatabaseTransaction,
22 repo: &repos::Model,
23 settings: &SyncSettings,
24) -> SyncResult<()> {
25 let desired = match detect_languages(baby, settings).await {
26 Ok(Some(langs)) => langs,
27 Ok(None) => return Ok(()),
28 Err(e) => {
29 tracing::warn!(repo_id = %repo.id, error = %e, "language detection failed; skipping");
30 return Ok(());
31 }
32 };
33
34 let existing = git_languages::Entity::find()
35 .filter(git_languages::Column::RepoId.eq(repo.id))
36 .all(txn)
37 .await
38 .map_err(SyncError::Db)?;
39
40 let mut existing_by_lang: std::collections::HashMap<String, git_languages::Model> = existing
41 .into_iter()
42 .map(|r| (r.language.clone(), r))
43 .collect();
44 let mut stale: std::collections::HashSet<String> = existing_by_lang.keys().cloned().collect();
45
46 let now = OffsetDateTime::now_utc();
47 for entry in &desired {
48 stale.remove(&entry.language);
49 let bp = entry.percentage_bp;
50 let size = entry.size;
51 match existing_by_lang.get(&entry.language) {
52 Some(row) if row.percentage_bp != bp || row.size != size => {
53 let mut am = row.clone().into_active_model();
54 am.percentage_bp = Set(bp);
55 am.size = Set(size);
56 am.update(txn).await.map_err(SyncError::Db)?;
57 }
58 Some(_) => {}
59 None => {
60 git_languages::ActiveModel {
61 id: Set(Uuid::now_v7()),
62 repo_id: Set(repo.id),
63 language: Set(entry.language.clone()),
64 percentage_bp: Set(bp),
65 size: Set(size),
66 created_at: Set(now),
67 }
68 .insert(txn)
69 .await
70 .map_err(SyncError::Db)?;
71 }
72 }
73 }
74
75 for lang in stale {
76 let Some(row) = existing_by_lang.remove(&lang) else {
77 continue;
78 };
79 git_languages::Entity::delete_by_id(row.id)
80 .exec(txn)
81 .await
82 .map_err(SyncError::Db)?;
83 }
84
85 if let Some(primary) = desired.first()
86 && repo.primary_language.as_deref() != Some(primary.language.as_str())
87 {
88 let mut am = repo.clone().into_active_model();
89 am.primary_language = Set(Some(primary.language.clone()));
90 am.update(txn).await.map_err(SyncError::Db)?;
91 }
92
93 Ok(())
94}
95
96#[derive(Debug, Clone)]
97struct LanguageEntry {
98 language: String,
99 percentage_bp: i32,
100 size: i64,
101}
102
103async fn detect_languages(
104 baby: &GitBaby,
105 settings: &SyncSettings,
106) -> SyncResult<Option<Vec<LanguageEntry>>> {
107 let timeout = Duration::from_secs(settings.language_timeout_secs);
108 let stats = tokio::time::timeout(timeout, walk_lang_stats(baby.clone())).await;
109
110 let entries: Vec<(String, i64)> = match stats {
111 Ok(Ok(map)) => map.into_iter().collect(),
112 Ok(Err(e)) => return Err(e),
113 Err(_) => {
114 tracing::warn!("language detection timed out");
115 return Ok(None);
116 }
117 };
118
119 let mut entries = entries;
120 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
121 entries.truncate(settings.max_languages);
122 let total: i64 = entries.iter().map(|(_, s)| *s).sum();
123 let out: Vec<LanguageEntry> = entries
124 .into_iter()
125 .filter(|(_, size)| *size > 0)
126 .map(|(language, size)| {
127 let bp = if total > 0 {
128 (((size as f64) / (total as f64)) * 10_000.0).round() as i32
129 } else {
130 0
131 };
132 LanguageEntry {
133 language,
134 percentage_bp: bp.clamp(0, 10_000),
135 size,
136 }
137 })
138 .collect();
139
140 Ok(Some(out))
141}
142
143async fn walk_lang_stats(baby: GitBaby) -> SyncResult<std::collections::HashMap<String, i64>> {
144 let entries = baby
145 .walk_tree(
146 "HEAD",
147 std::path::Path::new(""),
148 WalkTreeOptions {
149 recurse: true,
150 show_trees: false,
151 },
152 )
153 .await
154 .map_err(SyncError::Git)?;
155
156 let mut map: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
157 for e in entries {
158 if (e.mode == TreeEntryMode::Blob || e.mode == TreeEntryMode::Exec)
159 && let Some(lang) = ext_to_language(&e.name)
160 {
161 *map.entry(lang.to_string()).or_default() += e.size.unwrap_or(0) as i64;
162 }
163 }
164 Ok(map)
165}
Added lib/git/src/sync/lock.rs View lib/git/src/sync/lock.rs
@@ -0,0 +1,114 @@
1use std::process;
2use std::sync::Arc;
3
4use export::{AppRedis, RedisConnection};
5use uuid::Uuid;
6
7use crate::sync::error::SyncResult;
8
9const LOCK_KEY_PREFIX: &str = "git:sync:lock:";
10
11const REFRESH_SCRIPT: &str = "local v = redis.call('GET', KEYS[1])\nif v == ARGV[1] then\n redis.call('PEXPIRE', KEYS[1], ARGV[2])\n return 1\nend\nreturn 0";
12
13const RELEASE_SCRIPT: &str = "local v = redis.call('GET', KEYS[1])\nif v == ARGV[1] then\n return redis.call('DEL', KEYS[1])\nend\nreturn 0";
14
15macro_rules! dispatch {
16 ($conn:expr, |$c:ident| $body:expr) => {
17 match &mut $conn {
18 RedisConnection::Standalone($c) => $body,
19 RedisConnection::Cluster($c) => $body,
20 }
21 };
22}
23
24pub struct SyncLock {
25 redis: AppRedis,
26 key: String,
27 value: String,
28}
29
30impl SyncLock {
31 #[must_use]
32 pub fn value(&self) -> &str {
33 &self.value
34 }
35
36 #[must_use]
37 pub fn key(&self) -> &str {
38 &self.key
39 }
40
41 pub async fn refresh(&self, ttl_secs: u64) -> SyncResult<bool> {
42 let mut conn = self.redis.connection_clone();
43 let n: i64 = dispatch!(conn, |c| {
44 redis::cmd("EVAL")
45 .arg(REFRESH_SCRIPT)
46 .arg(1)
47 .arg(&self.key)
48 .arg(&self.value)
49 .arg(ttl_secs.saturating_mul(1000))
50 .query_async(c)
51 .await
52 })?;
53 Ok(n == 1)
54 }
55
56 pub async fn release(&self) -> SyncResult<()> {
57 let mut conn = self.redis.connection_clone();
58 let _: i64 = dispatch!(conn, |c| {
59 redis::cmd("EVAL")
60 .arg(RELEASE_SCRIPT)
61 .arg(1)
62 .arg(&self.key)
63 .arg(&self.value)
64 .query_async(c)
65 .await
66 })?;
67 Ok(())
68 }
69}
70
71fn lock_key(repo_id: Uuid) -> String {
72 format!("{LOCK_KEY_PREFIX}{repo_id}")
73}
74
75pub async fn acquire_lock(
76 redis: &AppRedis,
77 repo_id: Uuid,
78 ttl_secs: u64,
79) -> SyncResult<Option<SyncLock>> {
80 let key = lock_key(repo_id);
81 let value = format!("{}:{}", Uuid::now_v7(), process::id());
82
83 let mut conn = redis.connection_clone();
84 let acquired: bool = dispatch!(conn, |c| {
85 redis::cmd("SET")
86 .arg(&key)
87 .arg(&value)
88 .arg("NX")
89 .arg("EX")
90 .arg(ttl_secs)
91 .query_async(c)
92 .await
93 })?;
94
95 if !acquired {
96 return Ok(None);
97 }
98
99 Ok(Some(SyncLock {
100 redis: redis.clone(),
101 key,
102 value,
103 }))
104}
105
106#[allow(dead_code)]
107pub(crate) fn lock_handle(redis: AppRedis, key: String, value: String) -> SyncLock {
108 SyncLock { redis, key, value }
109}
110
111#[allow(dead_code)]
112pub(crate) fn lock_key_for(repo_id: Uuid) -> Arc<str> {
113 Arc::from(lock_key(repo_id))
114}
Added lib/git/src/sync/mod.rs View lib/git/src/sync/mod.rs
@@ -0,0 +1,27 @@
1pub mod branches;
2pub mod commits;
3pub mod error;
4pub mod event;
5pub mod fsck;
6pub mod languages;
7pub mod lock;
8pub mod post_receive;
9pub mod queue;
10pub mod stats;
11#[allow(clippy::module_inception)]
12pub mod sync;
13pub mod tags;
14pub mod webhook;
15pub mod worker;
16
17pub use error::{SyncError, SyncResult};
18pub use event::{
19 EnqueueError as SyncEnqueueError, PostReceiveRefUpdate, PostReceiveTask, enqueue_post_receive,
20 enqueue_post_receive_dlq,
21};
22pub use lock::{SyncLock, acquire_lock};
23pub use post_receive::{enqueue_post_receive_push, prepare_post_receive};
24pub use queue::SyncQueue;
25pub use sync::RepoSync;
26pub use webhook::{NoopWebhookTx, WebhookTx};
27pub use worker::{RunningWorker, SyncWorker};
Added lib/git/src/sync/post_receive.rs View lib/git/src/sync/post_receive.rs
@@ -0,0 +1,106 @@
1use std::fs;
2use std::io;
3#[cfg(unix)]
4use std::os::unix::fs::PermissionsExt;
5use std::path::{Path, PathBuf};
6
7use uuid::Uuid;
8
9use crate::sync::event::{PostReceiveRefUpdate, PostReceiveTask, enqueue_post_receive};
10use crate::sync::queue::SyncQueue;
11use config::git::SyncSettings;
12
13pub const POST_RECEIVE_HOOK: &str = "post-receive";
14pub const CAPTURE_FILE: &str = "_gitdata_post_receive";
15
16const HEX_OID_LEN: usize = 40;
17const HEX_OID_SHA256_LEN: usize = 64;
18
19pub fn hook_path(bare: &Path) -> PathBuf {
20 bare.join("hooks").join(POST_RECEIVE_HOOK)
21}
22
23pub fn capture_path(bare: &Path) -> PathBuf {
24 bare.join(CAPTURE_FILE)
25}
26
27pub fn ensure_post_receive_hook(bare: &Path) -> io::Result<()> {
28 let path = hook_path(bare);
29 if path.exists() {
30 tracing::warn!(
31 path = %path.display(),
32 "existing post-receive hook will not be overwritten; post-receive sync may not capture pushes"
33 );
34 return Ok(());
35 }
36 let hooks_dir = path.parent().expect("hook path always has a parent");
37 fs::create_dir_all(hooks_dir)?;
38 let script = format!(
39 "#!/bin/sh\n# gitdata post-receive capture hook\nwhile read old_oid new_oid refname; do\n printf '%s %s %s\\n' \"$old_oid\" \"$new_oid\" \"$refname\" >> \"$GIT_DIR/{CAPTURE_FILE}\"\ndone\n"
40 );
41 fs::write(&path, script)?;
42 #[cfg(unix)]
43 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
44 Ok(())
45}
46
47pub fn clear_post_receive_capture(bare: &Path) -> io::Result<()> {
48 match fs::remove_file(capture_path(bare)) {
49 Ok(()) => Ok(()),
50 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
51 Err(e) => Err(e),
52 }
53}
54
55pub fn prepare_post_receive(bare: &Path) -> io::Result<()> {
56 ensure_post_receive_hook(bare)?;
57 clear_post_receive_capture(bare)
58}
59
60pub fn take_post_receive_capture(bare: &Path) -> Vec<PostReceiveRefUpdate> {
61 let path = capture_path(bare);
62 let content = match fs::read_to_string(&path) {
63 Ok(c) => c,
64 Err(_) => return Vec::new(),
65 };
66 let _ = fs::remove_file(&path);
67 content.lines().filter_map(parse_capture_line).collect()
68}
69
70fn parse_capture_line(line: &str) -> Option<PostReceiveRefUpdate> {
71 let mut parts = line.split_whitespace();
72 let old_oid = parts.next()?;
73 let new_oid = parts.next()?;
74 let name = parts.next()?;
75 if !is_valid_oid(old_oid) || !is_valid_oid(new_oid) || name.is_empty() {
76 return None;
77 }
78 Some(PostReceiveRefUpdate::new(name, old_oid, new_oid))
79}
80
81fn is_valid_oid(oid: &str) -> bool {
82 let len = oid.len();
83 if len != HEX_OID_LEN && len != HEX_OID_SHA256_LEN {
84 return false;
85 }
86 oid.bytes().all(|b| b.is_ascii_hexdigit())
87}
88
89pub async fn enqueue_post_receive_push(
90 queue: &SyncQueue,
91 settings: &SyncSettings,
92 channel_id: Uuid,
93 repo_id: Uuid,
94 actor_id: Option<Uuid>,
95 bare: &Path,
96) -> Result<usize, EnqueueError> {
97 let refs = take_post_receive_capture(bare);
98 if refs.is_empty() {
99 return Ok(0);
100 }
101 let task = PostReceiveTask::new(repo_id, channel_id, actor_id, refs);
102 enqueue_post_receive(queue, settings, &task).await?;
103 Ok(task.refs.len())
104}
105
106pub use crate::sync::event::EnqueueError;
Added lib/git/src/sync/queue.rs View lib/git/src/sync/queue.rs
@@ -0,0 +1,92 @@
1use export::{AppRedis, RedisConnection};
2
3use crate::sync::error::SyncResult;
4
5macro_rules! dispatch {
6 ($conn:expr, |$c:ident| $body:expr) => {
7 match &mut $conn {
8 RedisConnection::Standalone($c) => $body,
9 RedisConnection::Cluster($c) => $body,
10 }
11 };
12}
13
14const INCR_EXPIRE_SCRIPT: &str = "local c = redis.call('INCR', KEYS[1])\nif c == 1 then\n redis.call('EXPIRE', KEYS[1], ARGV[1])\nend\nreturn c";
15
16#[derive(Clone)]
17pub struct SyncQueue {
18 redis: AppRedis,
19}
20
21impl SyncQueue {
22 #[must_use]
23 pub fn new(redis: AppRedis) -> Self {
24 Self { redis }
25 }
26
27 #[must_use]
28 pub fn redis(&self) -> &AppRedis {
29 &self.redis
30 }
31
32 pub async fn consume(&self, key: &str, timeout_secs: u64) -> SyncResult<Option<Vec<u8>>> {
33 let mut conn = self.redis.connection_clone();
34 let res: Option<(String, Vec<u8>)> = if timeout_secs == 0 {
35 dispatch!(conn, |c| {
36 redis::cmd("RPOP").arg(key).query_async(c).await
37 })
38 } else {
39 dispatch!(conn, |c| {
40 redis::cmd("BRPOP")
41 .arg(key)
42 .arg(timeout_secs)
43 .query_async(c)
44 .await
45 })
46 }?;
47 Ok(res.map(|(_, v)| v))
48 }
49
50 pub async fn publish_raw(
51 &self,
52 key: &str,
53 _partition: Option<&[u8]>,
54 payload: &[u8],
55 ) -> SyncResult<i64> {
56 let mut conn = self.redis.connection_clone();
57 let n: i64 = dispatch!(conn, |c| {
58 redis::cmd("LPUSH")
59 .arg(key)
60 .arg(payload)
61 .query_async(c)
62 .await
63 })?;
64 Ok(n)
65 }
66
67 pub async fn requeue(&self, key: &str, payload: &[u8]) -> SyncResult<i64> {
68 self.publish_raw(key, None, payload).await
69 }
70
71 pub async fn incr_with_ttl(&self, key: &str, ttl_secs: u64) -> SyncResult<i64> {
72 let mut conn = self.redis.connection_clone();
73 let n: i64 = dispatch!(conn, |c| {
74 redis::cmd("EVAL")
75 .arg(INCR_EXPIRE_SCRIPT)
76 .arg(1)
77 .arg(key)
78 .arg(ttl_secs)
79 .query_async(c)
80 .await
81 })?;
82 Ok(n)
83 }
84
85 pub async fn delete(&self, key: &str) -> SyncResult<()> {
86 let mut conn = self.redis.connection_clone();
87 let _: i64 = dispatch!(conn, |c| {
88 redis::cmd("DEL").arg(key).query_async(c).await
89 })?;
90 Ok(())
91 }
92}
Added lib/git/src/sync/stats.rs View lib/git/src/sync/stats.rs
@@ -0,0 +1,42 @@
1use models::repo::repos;
2use sea_orm::{ActiveModelTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, Set};
3
4use crate::service::repos::{count_commits, dir_size};
5use crate::sync::error::{SyncError, SyncResult};
6
7pub async fn sync_repo_stats(
8 txn: &DatabaseTransaction,
9 repo: &repos::Model,
10 repo_dir: &std::path::Path,
11 new_commits: usize,
12 branch_count: usize,
13 tag_count: usize,
14) -> SyncResult<()> {
15 let git_size = dir_size(repo_dir)
16 .await
17 .map_err(|e| SyncError::Internal(format!("dir_size: {e}")))? as i64;
18 let total_commits = count_commits(repo_dir)
19 .await
20 .map_err(|e| SyncError::Internal(format!("count_commits: {e}")))?
21 as i32;
22
23 let fresh = repos::Entity::find_by_id(repo.id)
24 .one(txn)
25 .await
26 .map_err(SyncError::Db)?
27 .ok_or_else(|| SyncError::NotFound(format!("repo {}", repo.id)))?;
28
29 let now = time::OffsetDateTime::now_utc();
30 let mut am = fresh.clone().into_active_model();
31 am.git_size = Set(git_size);
32 am.nums_commits = Set(total_commits);
33 am.nums_branches = Set(branch_count as i32);
34 am.nums_tags = Set(tag_count as i32);
35 if new_commits > 0 {
36 am.last_pushed_at = Set(Some(now));
37 }
38 am.updated_at = Set(now);
39 am.update(txn).await.map_err(SyncError::Db)?;
40
41 Ok(())
42}
Added lib/git/src/sync/sync.rs View lib/git/src/sync/sync.rs
@@ -0,0 +1,179 @@
1use std::path::PathBuf;
2use std::sync::Arc;
3
4use cache::CacheClient;
5use config::git::SyncSettings;
6use export::AppDatabase;
7use gitbaby::GitBaby;
8use models::repo::repos;
9use sea_orm::{DatabaseTransaction, TransactionTrait};
10use tokio::sync::watch;
11use uuid::Uuid;
12
13use crate::context::facade::GitContext;
14use crate::sync::branches;
15use crate::sync::commits::{CommitData, collect_new_commits_for_sync, sync_commits};
16use crate::sync::error::{SyncError, SyncResult};
17use crate::sync::fsck;
18use crate::sync::languages;
19use crate::sync::lock::{self, SyncLock};
20use crate::sync::stats;
21use crate::sync::tags;
22
23struct HeartbeatGuard {
24 handle: tokio::task::JoinHandle<()>,
25}
26
27impl Drop for HeartbeatGuard {
28 fn drop(&mut self) {
29 if !self.handle.is_finished() {
30 self.handle.abort();
31 }
32 }
33}
34
35#[derive(Clone)]
36pub struct RepoSync {
37 db: Arc<AppDatabase>,
38 repo: repos::Model,
39 baby: GitBaby,
40 redis: export::AppRedis,
41 #[allow(dead_code)]
42 cache: Arc<CacheClient>,
43 repo_root: PathBuf,
44 settings: SyncSettings,
45}
46
47impl RepoSync {
48 pub fn new(
49 db: Arc<AppDatabase>,
50 redis: export::AppRedis,
51 cache: Arc<CacheClient>,
52 repo: repos::Model,
53 repo_root: PathBuf,
54 settings: SyncSettings,
55 ) -> Self {
56 let ctx = GitContext {
57 model: repo.clone(),
58 cache: cache.clone(),
59 database: db.clone(),
60 repo_root: repo_root.clone(),
61 objects_root: repo_root.clone(),
62 };
63 Self {
64 db,
65 repo,
66 baby: GitBaby::new(Arc::new(ctx)),
67 redis,
68 cache,
69 repo_root,
70 settings,
71 }
72 }
73
74 #[must_use]
75 pub fn repo_id(&self) -> Uuid {
76 self.repo.id
77 }
78
79 #[must_use]
80 pub fn repo_dir(&self) -> PathBuf {
81 crate::context::facade::repo_dir_for(&self.repo_root, self.repo.id)
82 }
83
84 pub async fn sync(&self) -> SyncResult<()> {
85 let ttl = self.settings.lock_ttl_secs;
86 let lock = match lock::acquire_lock(&self.redis, self.repo.id, ttl).await? {
87 Some(l) => Arc::new(l),
88 None => return Err(SyncError::Locked),
89 };
90
91 let (tx, mut rx) = watch::channel(false);
92 let hb_lock = lock.clone();
93 let heartbeat = tokio::spawn(async move {
94 let interval = (ttl / 3).max(1);
95 loop {
96 tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
97 match hb_lock.refresh(ttl).await {
98 Ok(true) => {}
99 Ok(false) | Err(_) => {
100 let _ = tx.send(true);
101 break;
102 }
103 }
104 }
105 });
106 let heartbeat = HeartbeatGuard { handle: heartbeat };
107
108 let result = tokio::select! {
109 r = self.sync_work() => r,
110 _ = rx.changed() => Err(SyncError::Locked),
111 };
112
113 let is_locked_failure = matches!(result.as_ref(), Err(SyncError::Locked));
114 if !is_locked_failure {
115 let _ = lock.release().await;
116 }
117 drop(heartbeat);
118
119 result
120 }
121
122 pub async fn sync_work(&self) -> SyncResult<()> {
123 let baby = self.baby.clone();
124 let new_commits: Vec<CommitData> = collect_new_commits_for_sync(
125 &baby,
126 &self.db,
127 self.repo.id,
128 self.settings.max_commits_per_sync,
129 )
130 .await?;
131
132 let txn: DatabaseTransaction = self.db.write().begin().await.map_err(SyncError::Db)?;
133
134 let (branch_count, _synced_commits, tag_count) =
135 self.sync_refs_and_data(&txn, &new_commits).await?;
136
137 languages::sync_repo_languages(&baby, &txn, &self.repo, &self.settings).await?;
138
139 stats::sync_repo_stats(
140 &txn,
141 &self.repo,
142 &self.repo_dir(),
143 new_commits.len(),
144 branch_count,
145 tag_count,
146 )
147 .await?;
148
149 txn.commit().await.map_err(SyncError::Db)?;
150
151 fsck::run_gc(&self.repo_dir()).await;
152 fsck::run_fsck(&self.repo_dir()).await;
153
154 Ok(())
155 }
156
157 async fn sync_refs_and_data(
158 &self,
159 txn: &DatabaseTransaction,
160 new_commits: &[CommitData],
161 ) -> SyncResult<(usize, usize, usize)> {
162 let baby = self.baby.clone();
163 let branch_count = branches::sync_branches(&baby, txn, &self.repo).await?;
164 let synced = sync_commits(txn, self.repo.id, new_commits).await?;
165 let tag_count = tags::sync_tags(&baby, txn, &self.repo).await?;
166 Ok((branch_count, synced, tag_count))
167 }
168
169 pub async fn set_default_branch(&self, name: &str) -> SyncResult<()> {
170 self.baby
171 .set_default_branch(name)
172 .await
173 .map_err(SyncError::from)
174 }
175
176 pub async fn force_release_lock(lock: &SyncLock) -> SyncResult<()> {
177 lock.release().await
178 }
179}
Added lib/git/src/sync/tags.rs View lib/git/src/sync/tags.rs
@@ -0,0 +1,139 @@
1use std::collections::HashMap;
2
3use gitbaby::GitBaby;
4use gitbaby::tags::types::{ListTagsOptions, TagType};
5use models::git::git_tags;
6use models::git::git_tags::TagType as DbTagType;
7use models::repo::repos;
8use sea_orm::{
9 ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, QueryFilter,
10 Set,
11};
12use time::OffsetDateTime;
13use uuid::Uuid;
14
15use crate::sync::error::{SyncError, SyncResult};
16
17const MAX_TAGS: u64 = 10_000;
18
19#[derive(Debug, Clone)]
20pub(crate) struct TagTip {
21 pub name: String,
22 pub target_sha: String,
23 pub tag_type: DbTagType,
24 pub message: Option<String>,
25 pub tagger_name: Option<String>,
26 pub tagger_email: Option<String>,
27}
28
29pub async fn sync_tags(
30 baby: &GitBaby,
31 txn: &DatabaseTransaction,
32 repo: &repos::Model,
33) -> SyncResult<usize> {
34 let collected = collect_tag_tips(baby).await?;
35
36 let existing = git_tags::Entity::find()
37 .filter(git_tags::Column::RepoId.eq(repo.id))
38 .all(txn)
39 .await
40 .map_err(SyncError::Db)?;
41
42 let mut existing_by_name: HashMap<String, git_tags::Model> =
43 existing.into_iter().map(|t| (t.name.clone(), t)).collect();
44
45 let mut stale: std::collections::HashSet<String> = existing_by_name.keys().cloned().collect();
46
47 for tip in &collected {
48 stale.remove(&tip.name);
49 match existing_by_name.get(&tip.name) {
50 Some(row)
51 if row.commit_sha != tip.target_sha
52 || row.message != tip.message
53 || row.tag_type != tip.tag_type =>
54 {
55 let mut am = row.clone().into_active_model();
56 am.commit_sha = Set(tip.target_sha.clone());
57 am.tag_type = Set(tip.tag_type);
58 am.message = Set(tip.message.clone());
59 am.tagger_name = Set(tip.tagger_name.clone());
60 am.tagger_email = Set(tip.tagger_email.clone());
61 am.update(txn).await.map_err(SyncError::Db)?;
62 }
63 Some(_) => {}
64 None => {
65 git_tags::ActiveModel {
66 id: Set(Uuid::now_v7()),
67 repo_id: Set(repo.id),
68 name: Set(tip.name.clone()),
69 tag_type: Set(tip.tag_type),
70 commit_sha: Set(tip.target_sha.clone()),
71 message: Set(tip.message.clone()),
72 tagger_name: Set(tip.tagger_name.clone()),
73 tagger_email: Set(tip.tagger_email.clone()),
74 created_at: Set(OffsetDateTime::now_utc()),
75 }
76 .insert(txn)
77 .await
78 .map_err(SyncError::Db)?;
79 }
80 }
81 }
82
83 for name in stale {
84 let Some(row) = existing_by_name.remove(&name) else {
85 continue;
86 };
87 git_tags::Entity::delete_by_id(row.id)
88 .exec(txn)
89 .await
90 .map_err(SyncError::Db)?;
91 }
92
93 Ok(collected.len())
94}
95
96pub(crate) async fn collect_tag_tips(baby: &GitBaby) -> SyncResult<Vec<TagTip>> {
97 let refs = baby
98 .list_tags(ListTagsOptions::default())
99 .await
100 .map_err(SyncError::Git)?;
101 let mut tips = Vec::with_capacity(refs.len().min(MAX_TAGS as usize));
102
103 for info in refs.into_iter().take(MAX_TAGS as usize) {
104 let tag_type = match info.tag_type {
105 TagType::Annotated => DbTagType::Annotated,
106 TagType::Lightweight => DbTagType::Lightweight,
107 };
108 let name = info.name;
109 let mut tip = TagTip {
110 name: name.clone(),
111 target_sha: info.target_sha.to_string(),
112 tag_type,
113 message: info.message,
114 tagger_name: None,
115 tagger_email: None,
116 };
117 if matches!(tag_type, DbTagType::Annotated) {
118 match baby.find_tag(&name).await {
119 Ok(full) => {
120 tip.message = full.message;
121 if let Some(sig) = full.tagger {
122 tip.tagger_name = Some(sig.name);
123 tip.tagger_email = Some(sig.email);
124 }
125 }
126 Err(e) => {
127 tracing::warn!(
128 tag = %tip.name,
129 error = %e,
130 "annotated tag enrichment failed; storing without message/tagger"
131 );
132 }
133 }
134 }
135 tips.push(tip);
136 }
137
138 Ok(tips)
139}
Added lib/git/src/sync/webhook.rs View lib/git/src/sync/webhook.rs
@@ -0,0 +1,23 @@
1use std::error::Error;
2
3use crate::sync::event::PostReceiveTask;
4
5#[async_trait::async_trait]
6pub trait WebhookTx: Send + Sync {
7 async fn dispatch(&self, task: &PostReceiveTask) -> Result<(), Box<dyn Error + Send + Sync>>;
8}
9
10#[derive(Debug, Default, Clone)]
11pub struct NoopWebhookTx;
12
13#[async_trait::async_trait]
14impl WebhookTx for NoopWebhookTx {
15 async fn dispatch(&self, task: &PostReceiveTask) -> Result<(), Box<dyn Error + Send + Sync>> {
16 tracing::warn!(
17 repo_id = %task.repo_id,
18 refs = task.refs.len(),
19 "webhook dispatch not configured; message dropped (NoopWebhookTx)"
20 );
21 Ok(())
22 }
23}
Added lib/git/src/sync/worker.rs View lib/git/src/sync/worker.rs
@@ -0,0 +1,261 @@
1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::Duration;
4
5use cache::CacheClient;
6use config::git::SyncSettings;
7use export::{AppDatabase, AppRedis};
8use models::repo::repos;
9use sea_orm::EntityTrait;
10use tokio::task::JoinHandle;
11use tokio_util::sync::CancellationToken;
12use tracing::{debug, error, warn};
13
14use crate::sync::error::{SyncError, SyncResult};
15use crate::sync::event::PostReceiveTask;
16use crate::sync::queue::SyncQueue;
17use crate::sync::sync::RepoSync;
18use crate::sync::webhook::WebhookTx;
19
20const ATTEMPT_KEY_PREFIX: &str = "git:sync:attempts:";
21const ATTEMPT_TTL_SECS: u64 = 3600;
22const REQUEUE_BACKOFF_SECS: u64 = 5;
23
24#[derive(Clone)]
25pub struct SyncWorker {
26 db: Arc<AppDatabase>,
27 redis: AppRedis,
28 cache: Arc<CacheClient>,
29 repo_root: PathBuf,
30 queue: SyncQueue,
31 webhook: Arc<dyn WebhookTx>,
32 settings: SyncSettings,
33 cancel: CancellationToken,
34}
35
36pub struct RunningWorker {
37 handles: Vec<JoinHandle<()>>,
38}
39
40impl RunningWorker {
41 pub async fn shutdown(self) -> SyncResult<()> {
42 for h in self.handles {
43 if !h.is_finished() {
44 h.abort();
45 let _ = h.await;
46 }
47 }
48 Ok(())
49 }
50}
51
52impl SyncWorker {
53 #[allow(clippy::too_many_arguments)]
54 pub fn new(
55 db: Arc<AppDatabase>,
56 redis: AppRedis,
57 cache: Arc<CacheClient>,
58 repo_root: PathBuf,
59 queue: SyncQueue,
60 webhook: Arc<dyn WebhookTx>,
61 settings: SyncSettings,
62 cancel: CancellationToken,
63 ) -> Self {
64 let concurrency = settings.concurrency.max(1);
65 debug!(
66 concurrency,
67 topic = %settings.queue_topic,
68 "starting sync worker"
69 );
70 Self {
71 db,
72 redis,
73 cache,
74 repo_root,
75 queue,
76 webhook,
77 settings,
78 cancel,
79 }
80 }
81
82 pub async fn run(self) -> SyncResult<RunningWorker> {
83 let concurrency = self.settings.concurrency.max(1);
84 let topic = self.settings.queue_topic.clone();
85 let brpop = self.settings.brpop_timeout_secs;
86
87 let mut handles = Vec::with_capacity(concurrency);
88 for i in 0..concurrency {
89 let db = self.db.clone();
90 let redis = self.redis.clone();
91 let cache = self.cache.clone();
92 let repo_root = self.repo_root.clone();
93 let queue = self.queue.clone();
94 let webhook = self.webhook.clone();
95 let settings = self.settings.clone();
96 let cancel = self.cancel.clone();
97 let topic = topic.clone();
98
99 handles.push(tokio::spawn(async move {
100 run_consumer(
101 i, &db, &redis, &cache, &repo_root, &queue, webhook, &settings, &cancel,
102 &topic, brpop,
103 )
104 .await;
105 }));
106 }
107 Ok(RunningWorker { handles })
108 }
109}
110
111#[allow(clippy::too_many_arguments)]
112async fn run_consumer(
113 id: usize,
114 db: &Arc<AppDatabase>,
115 redis: &AppRedis,
116 cache: &Arc<CacheClient>,
117 repo_root: &Path,
118 queue: &SyncQueue,
119 webhook: Arc<dyn WebhookTx>,
120 settings: &SyncSettings,
121 cancel: &CancellationToken,
122 topic: &str,
123 brpop_timeout: u64,
124) {
125 debug!(consumer = id, "consumer started");
126 loop {
127 if cancel.is_cancelled() {
128 break;
129 }
130 let payload = tokio::select! {
131 biased;
132 _ = cancel.cancelled() => break,
133 res = queue.consume(topic, brpop_timeout) => match res {
134 Ok(Some(p)) => p,
135 Ok(None) => continue,
136 Err(e) => {
137 warn!(consumer = id, error = %e, "queue consume error; backing off");
138 tokio::time::sleep(Duration::from_secs(REQUEUE_BACKOFF_SECS)).await;
139 continue;
140 }
141 }
142 };
143
144 let task: PostReceiveTask = match serde_json::from_slice(&payload) {
145 Ok(t) => t,
146 Err(e) => {
147 error!(consumer = id, error = %e, "invalid sync task payload; dropping");
148 continue;
149 }
150 };
151
152 let repo = match repos::Entity::find_by_id(task.repo_id).one(db.read()).await {
153 Ok(Some(r)) => r,
154 Ok(None) => {
155 warn!(consumer = id, repo_id = %task.repo_id, "repo not found; skipping");
156 continue;
157 }
158 Err(e) => {
159 error!(consumer = id, repo_id = %task.repo_id, error = %e, "repo lookup failed; skipping");
160 continue;
161 }
162 };
163
164 let rsync = RepoSync::new(
165 db.clone(),
166 redis.clone(),
167 cache.clone(),
168 repo,
169 repo_root.to_path_buf(),
170 settings.clone(),
171 );
172
173 match rsync.sync().await {
174 Ok(()) => {
175 if let Err(e) = webhook.dispatch(&task).await {
176 warn!(consumer = id, repo_id = %task.repo_id, error = %e, "webhook dispatch failed");
177 }
178 }
179 Err(e) if e.is_locked() => {
180 warn!(consumer = id, repo_id = %task.repo_id, "sync locked; requeueing");
181 if let Err(e2) = requeue_or_dlq(queue, settings, &task, &payload).await {
182 error!(consumer = id, repo_id = %task.repo_id, error = %e2, "requeue failed");
183 }
184 }
185 Err(e) => {
186 error!(consumer = id, repo_id = %task.repo_id, error = %e, "sync task failed");
187 if let Err(e2) = requeue_or_dlq(queue, settings, &task, &payload).await {
188 error!(consumer = id, repo_id = %task.repo_id, error = %e2, "requeue failed");
189 }
190 }
191 }
192 }
193 debug!(consumer = id, "consumer stopped");
194}
195
196async fn requeue_or_dlq(
197 queue: &SyncQueue,
198 settings: &SyncSettings,
199 task: &PostReceiveTask,
200 payload: &[u8],
201) -> SyncResult<()> {
202 let key = format!("{ATTEMPT_KEY_PREFIX}{}", task.id);
203 let attempts = queue.incr_with_ttl(&key, ATTEMPT_TTL_SECS).await?;
204
205 if attempts as u64 > settings.max_lock_retries {
206 let _ = queue.delete(&key).await;
207 queue
208 .publish_raw(&settings.dlq_topic, Some(task.repo_id.as_bytes()), payload)
209 .await?;
210 warn!(
211 repo_id = %task.repo_id,
212 attempts,
213 "task exceeded retry limit; sent to DLQ"
214 );
215 return Ok(());
216 }
217
218 tokio::time::sleep(Duration::from_secs(REQUEUE_BACKOFF_SECS)).await;
219 queue
220 .publish_raw(
221 &settings.queue_topic,
222 Some(task.repo_id.as_bytes()),
223 payload,
224 )
225 .await?;
226 debug!(
227 repo_id = %task.repo_id,
228 attempts,
229 "re-queued retry task"
230 );
231 Ok(())
232}
233
234pub async fn run_once(
235 worker: &SyncWorker,
236 task: PostReceiveTask,
237 payload: Vec<u8>,
238) -> SyncResult<()> {
239 let repo = repos::Entity::find_by_id(task.repo_id)
240 .one(worker.db.read())
241 .await
242 .map_err(SyncError::Db)?
243 .ok_or_else(|| SyncError::NotFound(format!("repo {}", task.repo_id)))?;
244
245 let rsync = RepoSync::new(
246 worker.db.clone(),
247 worker.redis.clone(),
248 worker.cache.clone(),
249 repo,
250 worker.repo_root.clone(),
251 worker.settings.clone(),
252 );
253
254 let result = rsync.sync().await;
255 if result.is_ok() {
256 let _ = worker.webhook.dispatch(&task).await;
257 } else if let Err(e) = requeue_or_dlq(&worker.queue, &worker.settings, &task, &payload).await {
258 warn!(error = %e, "requeue failed in run_once");
259 }
260 result
261}
Added lib/git/src/lib.rs View lib/git/src/lib.rs
@@ -0,0 +1,8 @@
1pub mod context;
2pub mod graphql;
3pub mod lfs;
4pub mod service;
5pub mod share;
6pub mod smart;
7pub mod ssh;
8pub mod sync;