use bon::bon;
use futures::TryStreamExt;
use futures::stream::{Stream, StreamExt};
use serde::Deserialize;
use url::Url;
use super::diff::{self, HFFileDiff};
use super::{HFRepository, RepoType};
use crate::client::encode_ref;
use crate::error::HFResult;
use crate::{constants, retry};
#[derive(Debug, Clone, Deserialize)]
pub struct CommitAuthor {
pub user: Option<String>,
pub name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitCommitInfo {
pub id: String,
pub authors: Vec<CommitAuthor>,
pub date: Option<String>,
pub title: String,
pub message: String,
#[serde(default, rename = "formattedTitle")]
pub formatted_title: Option<String>,
#[serde(default, rename = "formattedMessage")]
pub formatted_message: Option<String>,
#[serde(default)]
pub parents: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitRefInfo {
pub name: String,
#[serde(rename = "ref")]
pub git_ref: String,
pub target_commit: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitRefs {
pub branches: Vec<GitRefInfo>,
pub tags: Vec<GitRefInfo>,
#[serde(default)]
pub converts: Vec<GitRefInfo>,
#[serde(default, rename = "pullRequests")]
pub pull_requests: Vec<GitRefInfo>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiffEntry {
pub path: Option<String>,
pub old_path: Option<String>,
pub status: Option<String>,
}
#[bon]
impl<T: RepoType> HFRepository<T> {
#[builder(finish_fn = send, derive(Debug, Clone))]
pub fn list_commits(
&self,
#[builder(into)]
revision: Option<String>,
limit: Option<usize>,
) -> HFResult<impl Stream<Item = HFResult<GitCommitInfo>> + '_> {
let revision = revision.as_deref().unwrap_or(constants::DEFAULT_REVISION);
let url_str = format!(
"{}/commits/{}",
self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
encode_ref(revision)
);
let url = Url::parse(&url_str)?;
Ok(self.hf_client.paginate(url, vec![], limit))
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub async fn list_refs(
&self,
#[builder(default)]
include_pull_requests: bool,
) -> HFResult<GitRefs> {
let url = format!("{}/refs", self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()));
let mut query: Vec<(&str, String)> = Vec::new();
if include_pull_requests {
query.push(("include_prs", "1".into()));
}
let headers = self.hf_client.auth_headers();
let response = retry::retry(self.hf_client.retry_config(), || {
self.hf_client
.http_client()
.get(&url)
.headers(headers.clone())
.query(&query)
.send()
})
.await?;
let repo_path = self.repo_path();
let response = self
.hf_client
.check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
.await?;
Ok(response.json().await?)
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub async fn get_commit_diff(
&self,
compare: &str,
) -> HFResult<String> {
let url = format!(
"{}/compare/{}",
self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
encode_ref(compare)
);
let headers = self.hf_client.auth_headers();
let response = retry::retry(self.hf_client.retry_config(), || {
self.hf_client.http_client().get(&url).headers(headers.clone()).send()
})
.await?;
let repo_path = self.repo_path();
let response = self
.hf_client
.check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
.await?;
Ok(response.text().await?)
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub async fn get_raw_diff(
&self,
compare: &str,
) -> HFResult<String> {
let url = format!(
"{}/compare/{}",
self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
encode_ref(compare)
);
let headers = self.hf_client.auth_headers();
let response = retry::retry(self.hf_client.retry_config(), || {
self.hf_client
.http_client()
.get(&url)
.headers(headers.clone())
.query(&[("raw", "true")])
.send()
})
.await?;
let repo_path = self.repo_path();
let response = self
.hf_client
.check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
.await?;
Ok(response.text().await?)
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub async fn get_raw_diff_stream(
&self,
#[builder(into)]
compare: String,
) -> HFResult<impl Stream<Item = HFResult<HFFileDiff>> + '_> {
let url = format!(
"{}/compare/{}",
self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
encode_ref(&compare)
);
let headers = self.hf_client.auth_headers();
let response = retry::retry(self.hf_client.retry_config(), || {
self.hf_client
.http_client()
.get(&url)
.headers(headers.clone())
.query(&[("raw", "true")])
.send()
})
.await?;
let repo_path = self.repo_path();
let response = self
.hf_client
.check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
.await?;
let byte_stream = response.bytes_stream().map(|r| r.map_err(std::io::Error::other));
Ok(diff::stream_raw_diff(byte_stream).map_err(Into::into))
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub async fn create_branch(
&self,
branch: &str,
#[builder(into)]
revision: Option<String>,
) -> HFResult<()> {
let url = format!(
"{}/branch/{}",
self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
encode_ref(branch)
);
let mut body = serde_json::Map::new();
if let Some(rev) = revision {
body.insert("startingPoint".into(), serde_json::Value::String(rev));
}
let headers = self.hf_client.auth_headers();
let response = retry::retry(self.hf_client.retry_config(), || {
self.hf_client
.http_client()
.post(&url)
.headers(headers.clone())
.json(&body)
.send()
})
.await?;
let repo_path = self.repo_path();
self.hf_client
.check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
.await?;
Ok(())
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub async fn delete_branch(
&self,
branch: &str,
) -> HFResult<()> {
let url = format!(
"{}/branch/{}",
self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
encode_ref(branch)
);
let headers = self.hf_client.auth_headers();
let response = retry::retry(self.hf_client.retry_config(), || {
self.hf_client.http_client().delete(&url).headers(headers.clone()).send()
})
.await?;
let repo_path = self.repo_path();
self.hf_client
.check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
.await?;
Ok(())
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub async fn create_tag(
&self,
tag: &str,
revision: Option<&str>,
#[builder(into)]
message: Option<String>,
) -> HFResult<()> {
let revision = revision.unwrap_or(constants::DEFAULT_REVISION);
let url = format!(
"{}/tag/{}",
self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
encode_ref(revision)
);
let mut body = serde_json::json!({ "tag": tag });
if let Some(m) = message {
body["message"] = serde_json::Value::String(m);
}
let headers = self.hf_client.auth_headers();
let response = retry::retry(self.hf_client.retry_config(), || {
self.hf_client
.http_client()
.post(&url)
.headers(headers.clone())
.json(&body)
.send()
})
.await?;
let repo_path = self.repo_path();
self.hf_client
.check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
.await?;
Ok(())
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub async fn delete_tag(
&self,
tag: &str,
) -> HFResult<()> {
let url =
format!("{}/tag/{}", self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()), encode_ref(tag));
let headers = self.hf_client.auth_headers();
let response = retry::retry(self.hf_client.retry_config(), || {
self.hf_client.http_client().delete(&url).headers(headers.clone()).send()
})
.await?;
let repo_path = self.repo_path();
self.hf_client
.check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
.await?;
Ok(())
}
}
#[cfg(feature = "blocking")]
#[bon]
impl<T: RepoType> crate::blocking::HFRepositorySync<T> {
#[builder(finish_fn = send, derive(Debug, Clone))]
pub fn list_commits(
&self,
#[builder(into)] revision: Option<String>,
limit: Option<usize>,
) -> HFResult<Vec<GitCommitInfo>> {
self.runtime.block_on(async move {
let stream = self.inner.list_commits().maybe_revision(revision).maybe_limit(limit).send()?;
futures::pin_mut!(stream);
let mut items = Vec::new();
while let Some(item) = stream.next().await {
items.push(item?);
}
Ok(items)
})
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub fn list_refs(&self, #[builder(default)] include_pull_requests: bool) -> HFResult<GitRefs> {
self.runtime
.block_on(self.inner.list_refs().include_pull_requests(include_pull_requests).send())
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub fn get_commit_diff(&self, compare: &str) -> HFResult<String> {
self.runtime.block_on(self.inner.get_commit_diff().compare(compare).send())
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub fn get_raw_diff(&self, compare: &str) -> HFResult<String> {
self.runtime.block_on(self.inner.get_raw_diff().compare(compare).send())
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub fn get_raw_diff_stream(&self, #[builder(into)] compare: String) -> HFResult<Vec<HFFileDiff>> {
self.runtime.block_on(async move {
let stream = self.inner.get_raw_diff_stream().compare(compare).send().await?;
futures::pin_mut!(stream);
let mut items = Vec::new();
while let Some(item) = stream.next().await {
items.push(item?);
}
Ok(items)
})
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub fn create_branch(&self, branch: &str, #[builder(into)] revision: Option<String>) -> HFResult<()> {
self.runtime
.block_on(self.inner.create_branch().branch(branch).maybe_revision(revision).send())
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub fn delete_branch(&self, branch: &str) -> HFResult<()> {
self.runtime.block_on(self.inner.delete_branch().branch(branch).send())
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub fn create_tag(
&self,
tag: &str,
revision: Option<&str>,
#[builder(into)] message: Option<String>,
) -> HFResult<()> {
self.runtime.block_on(
self.inner
.create_tag()
.tag(tag)
.maybe_revision(revision)
.maybe_message(message)
.send(),
)
}
#[builder(finish_fn = send, derive(Debug, Clone))]
pub fn delete_tag(&self, tag: &str) -> HFResult<()> {
self.runtime.block_on(self.inner.delete_tag().tag(tag).send())
}
}
#[cfg(test)]
mod tests {
use super::GitCommitInfo;
#[test]
fn test_git_commit_info_with_formatted_fields() {
let json = r#"{
"id":"abc123",
"authors":[{"user":"u","name":"User","email":"u@x"}],
"date":"2025-01-01T00:00:00Z",
"title":"feat: add thing",
"message":"feat: add thing\n\nLong body.",
"formattedTitle":"<p>feat: add thing</p>",
"formattedMessage":"<p>feat: add thing</p><p>Long body.</p>",
"parents":["p1"]
}"#;
let info: GitCommitInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.formatted_title.as_deref(), Some("<p>feat: add thing</p>"));
assert_eq!(info.formatted_message.as_deref(), Some("<p>feat: add thing</p><p>Long body.</p>"));
assert_eq!(info.parents, vec!["p1"]);
}
#[test]
fn test_git_commit_info_without_formatted_fields() {
let json = r#"{"id":"abc","authors":[],"date":null,"title":"t","message":"m"}"#;
let info: GitCommitInfo = serde_json::from_str(json).unwrap();
assert!(info.formatted_title.is_none());
assert!(info.formatted_message.is_none());
}
}