use std::collections::BTreeMap; use std::path::PathBuf; use gitbaby::repo::RepositoryFacade; use gix::actor::Signature; use gix::bstr::BString; use gix::date::Time; use gix::objs::Commit; use gix::objs::Write; use gix::{ObjectId, objs::Kind}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use crate::service::GitService; use crate::share::errors::{GitError, map_baby}; use crate::share::identity::load_identity; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SignatureDto { pub name: String, pub email: String, pub when: OffsetDateTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "lowercase")] pub enum ObjectKind { Merge, Squash, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct FileEntryDto { pub path: String, pub content: Vec, pub executable: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct WriteObjectResultDto { pub id: String, } fn gix_signature(sig: &SignatureDto, offset_seconds: i32) -> Signature { Signature { name: BString::from(sig.name.clone()), email: BString::from(sig.email.clone()), time: Time { seconds: sig.when.unix_timestamp(), offset: offset_seconds, }, } } impl GitService { pub async fn caller_signature( &self, caller: i64, when: OffsetDateTime, ) -> Result { let ident = load_identity(&self.state.database, caller).await?; Ok(SignatureDto { name: ident.name, email: ident.email, when, }) } /// Write a blob object (no ref update). pub async fn write_blob( &self, space_name: String, repo_name: String, data: Vec, ) -> Result { if data.len() as u64 > self.state.config.git.max_blob_size { return Err(GitError::InvalidArgument( "blob exceeds max size".to_string(), )); } let ctx = self.context(space_name, repo_name).await?; let repo = ctx.gix_repo().await.map_err(GitError::from)?; let id = repo .write_blob(data) .map_err(|e| GitError::Internal(format!("write blob failed: {e}")))?; Ok(WriteObjectResultDto { id: id.to_string() }) } /// Build a tree object from flat file entries (recursive dirs created /// implicitly). Returns the new tree id without touching any ref. pub async fn write_tree_from_files( &self, space_name: String, repo_name: String, files: Vec, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let repo = ctx.gix_repo().await.map_err(GitError::from)?; let mut blobs: BTreeMap = BTreeMap::new(); for f in &files { let path = clean_rel_path(&f.path)?; let id = repo .write_blob(&f.content) .map_err(|e| GitError::Internal(format!("write blob `{path}` failed: {e}")))?; blobs.insert(path, id.into()); } let tree_oid = build_tree_recursive(&repo, "", &blobs)?; Ok(WriteObjectResultDto { id: tree_oid.to_string(), }) } /// Write a commit object (merge = arbitrary parents, squash = one /// parent). Does not move any ref or HEAD. #[allow(clippy::too_many_arguments)] pub async fn commit_object( &self, space_name: String, repo_name: String, kind: ObjectKind, parents: Vec, tree: String, message: String, author: SignatureDto, committer: SignatureDto, ) -> Result { if parents.is_empty() { return Err(GitError::InvalidArgument( "commit requires at least one parent".to_string(), )); } let ctx = self.context(space_name, repo_name).await?; let baby = self.baby(&ctx); let parent_oids: Vec = match kind { ObjectKind::Merge => { let mut oids = Vec::with_capacity(parents.len()); for p in &parents { oids.push(baby.rev_parse(p).await.map_err(map_baby)?); } oids } ObjectKind::Squash => { vec![baby.rev_parse(&parents[0]).await.map_err(map_baby)?] } }; let tree_id = gix::ObjectId::from_hex(tree.trim().as_bytes()) .map_err(|_| GitError::InvalidArgument("invalid tree id".to_string()))?; let repo = ctx.gix_repo().await.map_err(GitError::from)?; let commit = Commit { tree: tree_id, parents: parent_oids.into(), author: gix_signature(&author, 0), committer: gix_signature(&committer, 0), encoding: None, message: BString::from(message.clone()), extra_headers: Vec::new(), }; let id = repo .write_object(&commit) .map_err(|e| GitError::Internal(format!("write commit failed: {e}")))?; Ok(WriteObjectResultDto { id: id.to_string() }) } } fn clean_rel_path(path: &str) -> Result { let p = PathBuf::from(path); if p.is_absolute() || p.components() .any(|c| matches!(c, std::path::Component::ParentDir)) || path.contains('\\') || path.chars().any(|c| c.is_control()) { return Err(GitError::InvalidArgument(format!( "invalid relative path `{path}`" ))); } Ok(p.to_string_lossy().replace('\\', "/")) } fn tree_entry_bytes(mode_octal: u32, name: &str, oid: ObjectId) -> Vec { let mut buf = Vec::with_capacity(64 + name.len()); use std::io::Write as _; let _ = write!(&mut buf, "{:o} ", mode_octal); buf.extend_from_slice(name.as_bytes()); buf.push(0); buf.extend_from_slice(oid.as_bytes()); buf } fn build_tree_recursive_sync( prefix: &str, blobs: &BTreeMap, write_tree: &mut dyn FnMut(&[u8]) -> Result, ) -> Result { // Group blobs by their first path segment relative to `prefix`. let mut direct: BTreeMap = BTreeMap::new(); let mut dir_blobs: BTreeMap> = BTreeMap::new(); for (path, oid) in blobs { if !path.starts_with(prefix) { continue; } let rest = &path[prefix.len()..]; if rest.is_empty() { continue; } let head = rest.split('/').next().unwrap_or_default(); if rest == head { direct.insert(head.to_string(), *oid); } else { let _dir = format!("{prefix}{head}/"); let entry = dir_blobs.entry(head.to_string()).or_default(); entry.insert(path.clone(), *oid); } } let mut entries: Vec<(String, ObjectId)> = Vec::new(); for (name, oid) in &direct { entries.push((name.clone(), *oid)); } for (dir_name, sub_blobs) in &dir_blobs { let sub_prefix = format!("{prefix}{dir_name}/"); let sub_oid = build_tree_recursive_sync(&sub_prefix, sub_blobs, write_tree)?; entries.push((format!("{dir_name}/"), sub_oid)); } entries.sort_by(|a, b| a.0.cmp(&b.0)); let mut buf: Vec = Vec::new(); for (name, oid) in entries { let (mode, name) = if let Some(plain) = name.strip_suffix('/') { (0o040000u32, plain) } else { (0o100644u32, name.as_str()) }; buf.extend_from_slice(&tree_entry_bytes(mode, name, oid)); } let mut object = format!("tree {}\0", buf.len()).into_bytes(); object.extend_from_slice(&buf); write_tree(&object) } fn build_tree_recursive( repo: &gix::Repository, prefix: &str, blobs: &BTreeMap, ) -> Result { let objects = repo.objects.clone(); let mut write_tree = move |data: &[u8]| -> Result { objects .write_buf(Kind::Tree, data) .map_err(|e| GitError::Internal(format!("write tree failed: {e}"))) }; build_tree_recursive_sync(prefix, blobs, &mut write_tree) }