use std::path::{Path, PathBuf}; use crate::rpcgit::commit::types::ListCommitsOptions; use serde::{Deserialize, Serialize}; use crate::service::GitService; use crate::service::cache::{self, IMMUTABLE_TTL}; use crate::share::errors::{GitError, map_baby}; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct PathLogEntryDto { pub path: String, pub short_sha: String, pub date: String, pub author: String, pub subject: String, } fn validate_target_dir(path: &Path) -> Result<(), GitError> { if path.as_os_str().is_empty() { return Err(GitError::InvalidArgument( "target directory path is empty".to_string(), )); } if path.is_absolute() || path.has_root() { return Err(GitError::InvalidArgument(format!( "target directory must be relative: `{}`", path.display() ))); } if path .components() .any(|c| matches!(c, std::path::Component::ParentDir)) { return Err(GitError::InvalidArgument(format!( "target directory may not contain `..`: `{}`", path.display() ))); } Ok(()) } fn iso_date(t: &time::OffsetDateTime) -> String { let secs = t.offset().whole_seconds(); let sign = if secs >= 0 { '+' } else { '-' }; let abs = secs.unsigned_abs(); format!( "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{:02}{:02}", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second(), abs / 3600, (abs % 3600) / 60, ) } fn first_line(s: &str) -> String { s.trim_end() .lines() .next() .unwrap_or("") .trim_end() .to_string() } impl GitService { pub async fn path_log( &self, space_name: String, repo_name: String, commit_hash: String, target_dir: PathBuf, ) -> Result, GitError> { validate_target_dir(&target_dir)?; if commit_hash.trim().is_empty() { return Err(GitError::InvalidArgument( "commit hash must not be empty".to_string(), )); } let ctx = self.context(space_name, repo_name).await?; let baby = self.baby(&ctx); let oid = baby .rev_parse(&commit_hash) .await .map_err(map_baby)? .to_string(); let key = format!( "repo.{}.path_log.{}.{}", ctx.model.id, oid, target_dir.display() ); cache::cached_fetch(&ctx.cache, &key, IMMUTABLE_TTL, || async { let entries = baby .get_tree_entries(&oid, &target_dir) .await .map_err(map_baby)?; let mut out = Vec::with_capacity(entries.len()); for entry in &entries { if entry.name.starts_with('.') { continue; } let entry_path = match target_dir.to_str() { Some("") | Some(".") => entry.name.clone(), Some(d) if d.ends_with('/') => format!("{d}{}", entry.name), Some(d) => format!("{d}/{}", entry.name), None => continue, }; let commits = baby .list_commits(ListCommitsOptions { revision: oid.clone(), path: Some(PathBuf::from(&entry_path)), max_count: Some(1), ..Default::default() }) .await .map_err(map_baby)?; let Some(commit) = commits.into_iter().next() else { continue; }; let hex = commit.id.to_string(); let short_sha = hex.get(..7).unwrap_or("").to_string(); out.push(PathLogEntryDto { path: entry_path, short_sha, date: iso_date(&commit.committer.time), author: commit.author.name.clone(), subject: first_line(&commit.message), }); } Ok(out) }) .await } }