use async_graphql::{Context, Object, Result}; use crate::GitRepository; use super::types::{ BlameObj, BlameRangeObj, BlobObj, CommitObj, ComparisonObj, ComparisonStatus, ContributorStatObj, DiffObj, DiffSummaryObj, GitObject, LanguageStatsObj, LogOptionsInput, MergeStatusObj, ObjectFormatEnum, PackStatsObj, PageInput, PatchObj, RefObj, RepoStatsObj, RepositoryObj, SubmoduleObj, TagObj, resolve_git_object, }; const DEFAULT_FIRST: i32 = 30; const MAX_FIRST: i32 = 100; #[derive(Clone)] pub struct GitQuery { pub(crate) repo: GitRepository, } impl GitQuery { pub fn new(repo: GitRepository) -> Self { Self { repo } } } fn clamp_first(first: Option) -> i32 { let v = first.unwrap_or(DEFAULT_FIRST).max(0); v.min(MAX_FIRST) } fn parse_oid(hex: &str) -> Result { gix::hash::ObjectId::from_hex(hex.as_bytes()) .map_err(|e| async_graphql::Error::new(format!("invalid object id `{hex}`: {e}"))) } fn parse_cursor(cursor: Option) -> Result { let Some(c) = cursor else { return Ok(0) }; c.parse::() .map_err(|e| async_graphql::Error::new(format!("invalid cursor `{c}`: {e}"))) } pub fn inject_repo<'a>(ctx: &'a Context<'_>, _repo: &'a GitRepository) { let _ = ctx; } #[Object] impl GitQuery { async fn api_version(&self) -> &'static str { "1.0.0" } async fn repository(&self) -> Result { let format = crate::object::impls::ObjectOps::object_format(&self.repo)?; let stats = crate::gc::impls::GcOps::gc_stats(&self.repo)?; Ok(RepositoryObj { object_format: ObjectFormatEnum::from(format), stats: RepoStatsObj::from(&stats), }) } async fn object_format(&self) -> Result { let f = crate::object::impls::ObjectOps::object_format(&self.repo)?; Ok(ObjectFormatEnum::from(f)) } async fn gc_stats(&self) -> Result { let s = crate::gc::impls::GcOps::gc_stats(&self.repo)?; Ok(RepoStatsObj::from(&s)) } async fn pack_stats(&self) -> Result { let s = crate::gc::impls::GcOps::gc_count_objects(&self.repo)?; Ok(PackStatsObj::from(&s)) } async fn object(&self, oid: String) -> Result { let id = parse_oid(&oid)?; resolve_git_object(&self.repo, id).await } async fn commit(&self, oid: Option, rev: Option) -> Result { let c = if let Some(hex) = oid { let id = parse_oid(&hex)?; crate::commit::impls::CommitOps::commit_by_id(&self.repo, &id)? } else if let Some(spec) = rev { crate::commit::impls::CommitOps::commit_commit(&self.repo, &spec)? } else { return Err(async_graphql::Error::new( "either `oid` or `rev` must be provided", )); }; Ok(CommitObj::from_commit(&c)) } async fn blob(&self, oid: String) -> Result { let id = parse_oid(&oid)?; let b = crate::blob::impls::BlobOps::blob_content(&self.repo, &id)?; Ok(BlobObj::from_content(&b)) } async fn tree(&self, oid: String) -> Result { let id = parse_oid(&oid)?; let t = crate::tree::impls::TreeOps::tree_read(&self.repo, &id)?; Ok(super::types::TreeObj::from(&t)) } async fn tag(&self, name: String) -> Result { let t = crate::tag::impls::TagOps::tag_get(&self.repo, &name)?; Ok(TagObj::from(&t)) } async fn ref_(&self, name: String) -> Result { let info = crate::refs::impls::RefsOps::refs_get(&self.repo, &name)?; Ok(RefObj::from_info(&info, &self.repo)) } async fn refs(&self, query: Option, page: Option) -> Result> { let pattern = query.unwrap_or_default(); let all = crate::refs::impls::RefsOps::refs_list_filtered(&self.repo, &pattern)?; let first = clamp_first(page.as_ref().and_then(|p| p.first)); let offset = parse_cursor(page.as_ref().and_then(|p| p.after.clone()))?; let slice = all.into_iter().skip(offset).take(first as usize); Ok(slice .map(|info| RefObj::from_info(&info, &self.repo)) .collect()) } async fn branches(&self, page: Option) -> Result> { let first = clamp_first(page.as_ref().and_then(|p| p.first)); let offset = parse_cursor(page.as_ref().and_then(|p| p.after.clone()))?; let all = crate::refs::impls::RefsOps::refs_list(&self.repo)?; let branches: Vec<_> = all .into_iter() .filter(|r| matches!(r.r#type, crate::refs::types::RefType::Branch)) .skip(offset) .take(first as usize) .collect(); Ok(branches .iter() .map(|info| RefObj::from_info(info, &self.repo)) .collect()) } async fn tags(&self, page: Option) -> Result> { let first = clamp_first(page.as_ref().and_then(|p| p.first)); let offset = parse_cursor(page.as_ref().and_then(|p| p.after.clone()))?; let tag_list = crate::tag::impls::TagOps::tag_list(&self.repo, 0, u64::MAX)?; let slice = tag_list.tags.into_iter().skip(offset).take(first as usize); Ok(slice.map(|t| TagObj::from(&t)).collect()) } async fn log(&self, opts: Option) -> Result> { let opts = opts.unwrap_or_default(); let first = clamp_first(opts.first); if opts.rev.is_none() || opts.rev.as_deref() == Some("HEAD") { let all = crate::commit::impls::CommitOps::commit_list(&self.repo, first as u64, 0)?; return Ok(all.commits.iter().map(CommitObj::from_commit).collect()); } let log_opts = crate::log::types::LogOptions { limit: Some(first as u64), skip: Some(parse_cursor(opts.after.clone())? as u64), cursor: opts.after, first_parent: opts.first_parent.unwrap_or(false), }; let list = crate::log::impls::LogOps::log_list(&self.repo, &log_opts)?; Ok(list.commits.iter().map(CommitObj::from_commit).collect()) } async fn diff(&self, base: String, head: String) -> Result { let d = crate::diff::impls::DiffOps::diff_diff(&self.repo, &base, &head)?; Ok(DiffObj::from(&d)) } async fn diff_short_stat(&self, base: String, head: String) -> Result { let s = crate::diff::impls::DiffOps::diff_short_stat(&self.repo, &base, &head)?; Ok(DiffSummaryObj::from(&s)) } async fn diff_files_changed(&self, base: String, head: String) -> Result> { crate::diff::impls::DiffOps::diff_files_changed_between(&self.repo, &base, &head) .map_err(Into::into) } async fn comparison(&self, base_ref: String, head_ref: String) -> Result { let stat = crate::diff::impls::DiffOps::diff_short_stat(&self.repo, &base_ref, &head_ref)?; let log = crate::log::impls::LogOps::log_commits_between(&self.repo, &base_ref, &head_ref, 256)?; let status = if stat.total_addition == 0 && stat.total_deletion == 0 { ComparisonStatus::Identical } else if stat.total_addition > 0 && stat.total_deletion == 0 { ComparisonStatus::Ahead } else if stat.total_addition == 0 { ComparisonStatus::Behind } else { ComparisonStatus::Diverged }; Ok(ComparisonObj { base_ref, head_ref, ahead: stat.total_addition, behind: stat.total_deletion, status, num_files: stat.num_files, commits: log.commits.iter().map(CommitObj::from_commit).collect(), }) } async fn blame(&self, oid: String, path: String) -> Result { let rev = parse_oid(&oid)?; let pathbuf = std::path::PathBuf::from(&path); let hunks = blame_porcelain(&self.repo, rev, &pathbuf)?; let total_lines = hunks.iter().map(|h| h.ending_line).max().unwrap_or(0); let ranges = hunks .into_iter() .map(|h| BlameRangeObj { starting_line: h.starting_line, ending_line: h.ending_line, age: 0, path: h.path, commit_oid: super::scalars::ObjectIdScalar(h.commit_oid), abbreviated_oid: h.abbreviated_oid, }) .collect(); Ok(BlameObj { ranges, total_lines, }) } async fn languages(&self) -> Result { let s = crate::analysis::impls::AnalysisOps::analysis_languages(&self.repo)?; Ok(LanguageStatsObj::from(&s)) } async fn contributors(&self) -> Result> { let v = crate::analysis::impls::AnalysisOps::analysis_contributors(&self.repo)?; Ok(v.iter().map(ContributorStatObj::from).collect()) } async fn submodules(&self, oid: String) -> Result> { let id = parse_oid(&oid)?; let list = crate::submodule::impls::SubmoduleOps::submodule_list(&self.repo, &id)?; Ok(list.submodules.iter().map(SubmoduleObj::from).collect()) } async fn conflict_check(&self, base: String, head: String) -> Result { let m = crate::conflict::impls::ConflictOps::conflict_check(&self.repo, &base, &head)?; Ok(MergeStatusObj::from(&m)) } async fn patch_generate(&self, base: String, head: String) -> Result { let p = crate::patch::impls::PatchOps::patch_generate(&self.repo, &base, &head)?; Ok(PatchObj::from(&p)) } } #[derive(Debug, Clone)] pub(crate) struct BlameHunk { pub starting_line: i32, pub ending_line: i32, pub commit_oid: gix::hash::ObjectId, pub abbreviated_oid: String, pub path: String, } fn blame_porcelain( repo: &crate::GitRepository, rev: gix::hash::ObjectId, path: &std::path::Path, ) -> Result> { use std::collections::BTreeMap; let rev_hex = rev.to_hex().to_string(); let rel_path = path.to_string_lossy().to_string(); let out = crate::gitcli::run( &repo.path, &["blame", "--porcelain", &rev_hex, "--", &rel_path], ) .map_err(|e| async_graphql::Error::new(format!("git blame failed: {e}")))?; let mut current_sha: Option = None; let mut final_line = 0_i32; let mut in_header = true; let mut last_path_per_sha: BTreeMap = BTreeMap::new(); let mut line_for_sha: BTreeMap = BTreeMap::new(); for raw_line in out.lines() { if raw_line.is_empty() { continue; } if in_header { let mut parts = raw_line.split_whitespace(); let sha = parts.next().unwrap_or(""); if let Some(final_l) = parts.nth(1) { if let Ok(n) = final_l.parse::() { final_line = n; } } if let Ok(id) = gix::hash::ObjectId::from_hex(sha.as_bytes()) { current_sha = Some(id); line_for_sha.entry(id).or_insert(final_line); } in_header = false; continue; } if let Some(rest) = raw_line.strip_prefix("filename ") { if let Some(sha) = current_sha { last_path_per_sha.insert(sha, rest.trim().to_string()); } continue; } if raw_line.starts_with('\t') { in_header = true; } } let mut entries: Vec<(gix::hash::ObjectId, i32, String)> = line_for_sha .into_iter() .map(|(sha, line)| { let path = last_path_per_sha .get(&sha) .cloned() .unwrap_or_else(|| rel_path.clone()); (sha, line, path) }) .collect(); entries.sort_by_key(|(_, line, _)| *line); let mut hunks = Vec::with_capacity(entries.len()); for (sha, line, path) in entries { let hex = sha.to_hex().to_string(); hunks.push(BlameHunk { starting_line: line, ending_line: line, commit_oid: sha, abbreviated_oid: hex.get(..7).unwrap_or("").to_string(), path, }); } Ok(hunks) }