Skip to main content

hf_hub/repository/
commits.rs

1//! Commit history, refs, and revision comparison helpers for repositories.
2//!
3//! This module groups three related workflows:
4//!
5//! - Use [`HFRepository::list_commits`] to walk commit history and [`HFRepository::list_refs`] to inspect branches,
6//!   tags, convert refs, and optional pull-request refs.
7//! - Use [`HFRepository::get_commit_diff`] for the Hub's non-raw compare payload as text.
8//! - Use [`HFRepository::get_raw_diff`] for the full raw diff text, or [`HFRepository::get_raw_diff_stream`] to parse
9//!   that raw diff incrementally into [`HFFileDiff`] entries.
10//!
11//! The same module also hosts branch and tag creation/deletion helpers because
12//! they operate on the same repository revision namespace.
13
14use bon::bon;
15use futures::TryStreamExt;
16use futures::stream::{Stream, StreamExt};
17use serde::Deserialize;
18use url::Url;
19
20use super::diff::{self, HFFileDiff};
21use super::{HFRepository, RepoType};
22use crate::client::encode_ref;
23use crate::error::HFResult;
24use crate::{constants, retry};
25
26/// Author entry attached to a commit, as returned by the commit history endpoint.
27///
28/// All fields are optional because the Hub only surfaces the identifying fields it has
29/// (a linked Hub user, or the raw git name/email).
30#[derive(Debug, Clone, Deserialize)]
31pub struct CommitAuthor {
32    /// Hub username, when the commit author is linked to a Hub account.
33    pub user: Option<String>,
34    /// Git author name as recorded on the commit.
35    pub name: Option<String>,
36    /// Git author email as recorded on the commit.
37    pub email: Option<String>,
38}
39
40/// A single commit entry returned by the commit history endpoint.
41///
42/// Returned by [`HFRepository::list_commits`].
43#[derive(Debug, Clone, Deserialize)]
44pub struct GitCommitInfo {
45    /// Full commit SHA.
46    pub id: String,
47    /// Commit authors as returned by the Hub.
48    pub authors: Vec<CommitAuthor>,
49    /// Commit timestamp in ISO 8601 format, when available.
50    pub date: Option<String>,
51    /// Commit title/summary line.
52    pub title: String,
53    /// Full commit message.
54    pub message: String,
55    /// HTML-formatted commit title. Returned only when the request asks the Hub to format the
56    /// message (e.g., `?formatted=true`).
57    #[serde(default, rename = "formattedTitle")]
58    pub formatted_title: Option<String>,
59    /// HTML-formatted commit message. Returned only when the request asks the Hub to format the
60    /// message (e.g., `?formatted=true`).
61    #[serde(default, rename = "formattedMessage")]
62    pub formatted_message: Option<String>,
63    /// Parent commit SHAs.
64    #[serde(default)]
65    pub parents: Vec<String>,
66}
67
68/// A single git ref (branch, tag, convert, or pull-request ref) and the commit it points to.
69#[derive(Debug, Clone, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct GitRefInfo {
72    /// Short ref name such as `"main"` or `"v1.0.0"`.
73    pub name: String,
74    /// Full git ref name such as `"refs/heads/main"`.
75    #[serde(rename = "ref")]
76    pub git_ref: String,
77    /// Commit SHA the ref currently points to.
78    pub target_commit: String,
79}
80
81/// All git refs on a repository — branches, tags, converts, and pull-request refs.
82///
83/// Returned by [`HFRepository::list_refs`].
84#[derive(Debug, Clone, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct GitRefs {
87    /// Branch refs on the repository.
88    pub branches: Vec<GitRefInfo>,
89    /// Tag refs on the repository.
90    pub tags: Vec<GitRefInfo>,
91    /// Convert refs exposed by the Hub, when present.
92    #[serde(default)]
93    pub converts: Vec<GitRefInfo>,
94    /// Pull-request refs, only populated when requested.
95    #[serde(default, rename = "pullRequests")]
96    pub pull_requests: Vec<GitRefInfo>,
97}
98
99/// A single file entry in the Hub's non-raw compare payload.
100///
101/// This type is useful if you want to deserialize the response body returned by
102/// [`HFRepository::get_commit_diff`] yourself.
103#[derive(Debug, Clone, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct DiffEntry {
106    /// Destination path for the changed file, when present.
107    pub path: Option<String>,
108    /// Previous path for renames or moves, when present.
109    pub old_path: Option<String>,
110    /// Hub-provided status string for the change.
111    pub status: Option<String>,
112}
113
114#[bon]
115impl<T: RepoType> HFRepository<T> {
116    /// Stream commit history for the repository at a given revision.
117    ///
118    /// Returns `HFResult<impl Stream<Item = HFResult<GitCommitInfo>>>`. Pagination is automatic.
119    ///
120    /// # Parameters
121    ///
122    /// - `revision`: Git revision (branch, tag, or commit SHA). Defaults to the main branch.
123    /// - `limit`: maximum number of commits yielded.
124    #[builder(finish_fn = send, derive(Debug, Clone))]
125    pub fn list_commits(
126        &self,
127        /// Git revision (branch, tag, or commit SHA). Defaults to the main branch.
128        #[builder(into)]
129        revision: Option<String>,
130        /// Maximum number of commits yielded.
131        limit: Option<usize>,
132    ) -> HFResult<impl Stream<Item = HFResult<GitCommitInfo>> + '_> {
133        let revision = revision.as_deref().unwrap_or(constants::DEFAULT_REVISION);
134        let url_str = format!(
135            "{}/commits/{}",
136            self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
137            encode_ref(revision)
138        );
139        let url = Url::parse(&url_str)?;
140        Ok(self.hf_client.paginate(url, vec![], limit))
141    }
142
143    /// Fetch all branches, tags, and optionally pull-request refs for the repository.
144    ///
145    /// Endpoint: `GET /api/{repo_type}s/{repo_id}/refs`.
146    ///
147    /// # Parameters
148    ///
149    /// - `include_pull_requests` (default `false`): include pull-request refs in the listing.
150    #[builder(finish_fn = send, derive(Debug, Clone))]
151    pub async fn list_refs(
152        &self,
153        /// Include pull-request refs in the listing.
154        #[builder(default)]
155        include_pull_requests: bool,
156    ) -> HFResult<GitRefs> {
157        let url = format!("{}/refs", self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()));
158        let mut query: Vec<(&str, String)> = Vec::new();
159        if include_pull_requests {
160            query.push(("include_prs", "1".into()));
161        }
162
163        let headers = self.hf_client.auth_headers();
164        let response = retry::retry(self.hf_client.retry_config(), || {
165            self.hf_client
166                .http_client()
167                .get(&url)
168                .headers(headers.clone())
169                .query(&query)
170                .send()
171        })
172        .await?;
173
174        let repo_path = self.repo_path();
175        let response = self
176            .hf_client
177            .check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
178            .await?;
179        Ok(response.json().await?)
180    }
181
182    /// Fetch the Hub's non-raw compare payload as text.
183    ///
184    /// This returns the response body from the standard `/compare/{compare}` endpoint. Use
185    /// [`HFRepository::get_raw_diff`] for raw git-style diff text or
186    /// [`HFRepository::get_raw_diff_stream`] for parsed [`HFFileDiff`] entries.
187    ///
188    /// Endpoint: `GET /api/{repo_type}s/{repo_id}/compare/{compare}`.
189    ///
190    /// # Parameters
191    ///
192    /// - `compare` (required): revision spec describing what to compare. Either:
193    ///   - A single revision (branch name, tag, or commit SHA), compared against its parent (e.g., `"main"`, `"v1.0"`,
194    ///     `"abc123…"`), or
195    ///   - Two revisions in `<base>..<head>` form (two dots), comparing `base` to `head` (e.g., `"main..feature"`,
196    ///     `"<sha1>..<sha2>"`).
197    #[builder(finish_fn = send, derive(Debug, Clone))]
198    pub async fn get_commit_diff(
199        &self,
200        /// Revision spec describing what to compare. Either:
201        /// - A single revision (branch name, tag, or commit SHA), compared against its parent (e.g., `"main"`,
202        ///   `"v1.0"`, `"abc123…"`), or
203        /// - Two revisions in `<base>..<head>` form (two dots), comparing `base` to `head` (e.g., `"main..feature"`,
204        ///   `"<sha1>..<sha2>"`).
205        compare: &str,
206    ) -> HFResult<String> {
207        let url = format!(
208            "{}/compare/{}",
209            self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
210            encode_ref(compare)
211        );
212
213        let headers = self.hf_client.auth_headers();
214        let response = retry::retry(self.hf_client.retry_config(), || {
215            self.hf_client.http_client().get(&url).headers(headers.clone()).send()
216        })
217        .await?;
218
219        let repo_path = self.repo_path();
220        let response = self
221            .hf_client
222            .check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
223            .await?;
224        Ok(response.text().await?)
225    }
226
227    /// Fetch the raw diff payload between two revisions as a string.
228    ///
229    /// Prefer [`HFRepository::get_raw_diff_stream`] when you want file-level metadata without
230    /// buffering the entire diff response in memory.
231    ///
232    /// Endpoint: `GET /api/{repo_type}s/{repo_id}/compare/{compare}?raw=true`.
233    ///
234    /// # Parameters
235    ///
236    /// - `compare` (required): revision spec describing what to compare. Either:
237    ///   - A single revision (branch name, tag, or commit SHA), compared against its parent (e.g., `"main"`, `"v1.0"`,
238    ///     `"abc123…"`), or
239    ///   - Two revisions in `<base>..<head>` form (two dots), comparing `base` to `head` (e.g., `"main..feature"`,
240    ///     `"<sha1>..<sha2>"`).
241    #[builder(finish_fn = send, derive(Debug, Clone))]
242    pub async fn get_raw_diff(
243        &self,
244        /// Revision spec describing what to compare. Either:
245        /// - A single revision (branch name, tag, or commit SHA), compared against its parent (e.g., `"main"`,
246        ///   `"v1.0"`, `"abc123…"`), or
247        /// - Two revisions in `<base>..<head>` form (two dots), comparing `base` to `head` (e.g., `"main..feature"`,
248        ///   `"<sha1>..<sha2>"`).
249        compare: &str,
250    ) -> HFResult<String> {
251        let url = format!(
252            "{}/compare/{}",
253            self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
254            encode_ref(compare)
255        );
256
257        let headers = self.hf_client.auth_headers();
258        let response = retry::retry(self.hf_client.retry_config(), || {
259            self.hf_client
260                .http_client()
261                .get(&url)
262                .headers(headers.clone())
263                .query(&[("raw", "true")])
264                .send()
265        })
266        .await?;
267
268        let repo_path = self.repo_path();
269        let response = self
270            .hf_client
271            .check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
272            .await?;
273        Ok(response.text().await?)
274    }
275
276    /// Fetch the raw diff between two revisions as a parsed stream of [`HFFileDiff`] entries.
277    ///
278    /// Each `Ok` item is one parsed diff entry; malformed lines are `Err` items.
279    ///
280    /// Endpoint: `GET /api/{repo_type}s/{repo_id}/compare/{compare}?raw=true`.
281    ///
282    /// # Parameters
283    ///
284    /// - `compare` (required): revision spec describing what to compare. Either:
285    ///   - A single revision (branch name, tag, or commit SHA), compared against its parent (e.g., `"main"`, `"v1.0"`,
286    ///     `"abc123…"`), or
287    ///   - Two revisions in `<base>..<head>` form (two dots), comparing `base` to `head` (e.g., `"main..feature"`,
288    ///     `"<sha1>..<sha2>"`).
289    #[builder(finish_fn = send, derive(Debug, Clone))]
290    pub async fn get_raw_diff_stream(
291        &self,
292        /// Revision spec describing what to compare. Either:
293        /// - A single revision (branch name, tag, or commit SHA), compared against its parent (e.g., `"main"`,
294        ///   `"v1.0"`, `"abc123…"`), or
295        /// - Two revisions in `<base>..<head>` form (two dots), comparing `base` to `head` (e.g., `"main..feature"`,
296        ///   `"<sha1>..<sha2>"`).
297        #[builder(into)]
298        compare: String,
299    ) -> HFResult<impl Stream<Item = HFResult<HFFileDiff>> + '_> {
300        let url = format!(
301            "{}/compare/{}",
302            self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
303            encode_ref(&compare)
304        );
305
306        let headers = self.hf_client.auth_headers();
307        let response = retry::retry(self.hf_client.retry_config(), || {
308            self.hf_client
309                .http_client()
310                .get(&url)
311                .headers(headers.clone())
312                .query(&[("raw", "true")])
313                .send()
314        })
315        .await?;
316
317        let repo_path = self.repo_path();
318        let response = self
319            .hf_client
320            .check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
321            .await?;
322        let byte_stream = response.bytes_stream().map(|r| r.map_err(std::io::Error::other));
323        Ok(diff::stream_raw_diff(byte_stream).map_err(Into::into))
324    }
325
326    /// Create a new branch, optionally starting from a specific revision.
327    ///
328    /// Endpoint: `POST /api/{repo_type}s/{repo_id}/branch/{branch}`.
329    ///
330    /// # Parameters
331    ///
332    /// - `branch` (required): name of the branch to create.
333    /// - `revision`: revision to branch from. Defaults to the current main branch head.
334    #[builder(finish_fn = send, derive(Debug, Clone))]
335    pub async fn create_branch(
336        &self,
337        /// Name of the branch to create.
338        branch: &str,
339        /// Revision to branch from. Defaults to the current main branch head.
340        #[builder(into)]
341        revision: Option<String>,
342    ) -> HFResult<()> {
343        let url = format!(
344            "{}/branch/{}",
345            self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
346            encode_ref(branch)
347        );
348
349        let mut body = serde_json::Map::new();
350        if let Some(rev) = revision {
351            body.insert("startingPoint".into(), serde_json::Value::String(rev));
352        }
353
354        let headers = self.hf_client.auth_headers();
355        let response = retry::retry(self.hf_client.retry_config(), || {
356            self.hf_client
357                .http_client()
358                .post(&url)
359                .headers(headers.clone())
360                .json(&body)
361                .send()
362        })
363        .await?;
364
365        let repo_path = self.repo_path();
366        self.hf_client
367            .check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
368            .await?;
369        Ok(())
370    }
371
372    /// Delete a branch from the repository.
373    ///
374    /// Endpoint: `DELETE /api/{repo_type}s/{repo_id}/branch/{branch}`.
375    ///
376    /// # Parameters
377    ///
378    /// - `branch` (required): name of the branch to delete.
379    #[builder(finish_fn = send, derive(Debug, Clone))]
380    pub async fn delete_branch(
381        &self,
382        /// Name of the branch to delete.
383        branch: &str,
384    ) -> HFResult<()> {
385        let url = format!(
386            "{}/branch/{}",
387            self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
388            encode_ref(branch)
389        );
390
391        let headers = self.hf_client.auth_headers();
392        let response = retry::retry(self.hf_client.retry_config(), || {
393            self.hf_client.http_client().delete(&url).headers(headers.clone()).send()
394        })
395        .await?;
396
397        let repo_path = self.repo_path();
398        self.hf_client
399            .check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
400            .await?;
401        Ok(())
402    }
403
404    /// Create a lightweight or annotated tag, optionally at a specific revision.
405    ///
406    /// Endpoint: `POST /api/{repo_type}s/{repo_id}/tag/{revision}`.
407    ///
408    /// # Parameters
409    ///
410    /// - `tag` (required): name of the tag to create.
411    /// - `revision`: revision to tag. Defaults to the current main branch head.
412    /// - `message`: annotation message for the tag.
413    #[builder(finish_fn = send, derive(Debug, Clone))]
414    pub async fn create_tag(
415        &self,
416        /// Name of the tag to create.
417        tag: &str,
418        /// Revision to tag. Defaults to the current main branch head.
419        revision: Option<&str>,
420        /// Annotation message for the tag.
421        #[builder(into)]
422        message: Option<String>,
423    ) -> HFResult<()> {
424        let revision = revision.unwrap_or(constants::DEFAULT_REVISION);
425        let url = format!(
426            "{}/tag/{}",
427            self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()),
428            encode_ref(revision)
429        );
430
431        let mut body = serde_json::json!({ "tag": tag });
432        if let Some(m) = message {
433            body["message"] = serde_json::Value::String(m);
434        }
435
436        let headers = self.hf_client.auth_headers();
437        let response = retry::retry(self.hf_client.retry_config(), || {
438            self.hf_client
439                .http_client()
440                .post(&url)
441                .headers(headers.clone())
442                .json(&body)
443                .send()
444        })
445        .await?;
446
447        let repo_path = self.repo_path();
448        self.hf_client
449            .check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
450            .await?;
451        Ok(())
452    }
453
454    /// Delete a tag from the repository.
455    ///
456    /// Endpoint: `DELETE /api/{repo_type}s/{repo_id}/tag/{tag}`.
457    ///
458    /// # Parameters
459    ///
460    /// - `tag` (required): name of the tag to delete.
461    #[builder(finish_fn = send, derive(Debug, Clone))]
462    pub async fn delete_tag(
463        &self,
464        /// Name of the tag to delete.
465        tag: &str,
466    ) -> HFResult<()> {
467        let url =
468            format!("{}/tag/{}", self.hf_client.api_url(self.repo_type.plural(), &self.repo_path()), encode_ref(tag));
469
470        let headers = self.hf_client.auth_headers();
471        let response = retry::retry(self.hf_client.retry_config(), || {
472            self.hf_client.http_client().delete(&url).headers(headers.clone()).send()
473        })
474        .await?;
475
476        let repo_path = self.repo_path();
477        self.hf_client
478            .check_response(response, Some(&repo_path), crate::error::NotFoundContext::Repo)
479            .await?;
480        Ok(())
481    }
482}
483
484#[cfg(feature = "blocking")]
485#[bon]
486impl<T: RepoType> crate::blocking::HFRepositorySync<T> {
487    /// Blocking counterpart of [`HFRepository::list_commits`]. Collects the stream into a
488    /// `Vec<GitCommitInfo>`. See the async method for parameters and behavior.
489    #[builder(finish_fn = send, derive(Debug, Clone))]
490    pub fn list_commits(
491        &self,
492        #[builder(into)] revision: Option<String>,
493        limit: Option<usize>,
494    ) -> HFResult<Vec<GitCommitInfo>> {
495        self.runtime.block_on(async move {
496            let stream = self.inner.list_commits().maybe_revision(revision).maybe_limit(limit).send()?;
497            futures::pin_mut!(stream);
498            let mut items = Vec::new();
499            while let Some(item) = stream.next().await {
500                items.push(item?);
501            }
502            Ok(items)
503        })
504    }
505
506    /// Blocking counterpart of [`HFRepository::list_refs`]. See the async method for parameters
507    /// and behavior.
508    #[builder(finish_fn = send, derive(Debug, Clone))]
509    pub fn list_refs(&self, #[builder(default)] include_pull_requests: bool) -> HFResult<GitRefs> {
510        self.runtime
511            .block_on(self.inner.list_refs().include_pull_requests(include_pull_requests).send())
512    }
513
514    /// Blocking counterpart of [`HFRepository::get_commit_diff`]. See the async method for
515    /// parameters and behavior.
516    #[builder(finish_fn = send, derive(Debug, Clone))]
517    pub fn get_commit_diff(&self, compare: &str) -> HFResult<String> {
518        self.runtime.block_on(self.inner.get_commit_diff().compare(compare).send())
519    }
520
521    /// Blocking counterpart of [`HFRepository::get_raw_diff`]. See the async method for
522    /// parameters and behavior.
523    #[builder(finish_fn = send, derive(Debug, Clone))]
524    pub fn get_raw_diff(&self, compare: &str) -> HFResult<String> {
525        self.runtime.block_on(self.inner.get_raw_diff().compare(compare).send())
526    }
527
528    /// Blocking counterpart of [`HFRepository::get_raw_diff_stream`]. Collects the parsed stream
529    /// into a `Vec<HFFileDiff>`. See the async method for parameters and behavior.
530    #[builder(finish_fn = send, derive(Debug, Clone))]
531    pub fn get_raw_diff_stream(&self, #[builder(into)] compare: String) -> HFResult<Vec<HFFileDiff>> {
532        self.runtime.block_on(async move {
533            let stream = self.inner.get_raw_diff_stream().compare(compare).send().await?;
534            futures::pin_mut!(stream);
535            let mut items = Vec::new();
536            while let Some(item) = stream.next().await {
537                items.push(item?);
538            }
539            Ok(items)
540        })
541    }
542
543    /// Blocking counterpart of [`HFRepository::create_branch`]. See the async method for
544    /// parameters and behavior.
545    #[builder(finish_fn = send, derive(Debug, Clone))]
546    pub fn create_branch(&self, branch: &str, #[builder(into)] revision: Option<String>) -> HFResult<()> {
547        self.runtime
548            .block_on(self.inner.create_branch().branch(branch).maybe_revision(revision).send())
549    }
550
551    /// Blocking counterpart of [`HFRepository::delete_branch`]. See the async method for
552    /// parameters and behavior.
553    #[builder(finish_fn = send, derive(Debug, Clone))]
554    pub fn delete_branch(&self, branch: &str) -> HFResult<()> {
555        self.runtime.block_on(self.inner.delete_branch().branch(branch).send())
556    }
557
558    /// Blocking counterpart of [`HFRepository::create_tag`]. See the async method for parameters
559    /// and behavior.
560    #[builder(finish_fn = send, derive(Debug, Clone))]
561    pub fn create_tag(
562        &self,
563        tag: &str,
564        revision: Option<&str>,
565        #[builder(into)] message: Option<String>,
566    ) -> HFResult<()> {
567        self.runtime.block_on(
568            self.inner
569                .create_tag()
570                .tag(tag)
571                .maybe_revision(revision)
572                .maybe_message(message)
573                .send(),
574        )
575    }
576
577    /// Blocking counterpart of [`HFRepository::delete_tag`]. See the async method for parameters
578    /// and behavior.
579    #[builder(finish_fn = send, derive(Debug, Clone))]
580    pub fn delete_tag(&self, tag: &str) -> HFResult<()> {
581        self.runtime.block_on(self.inner.delete_tag().tag(tag).send())
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::GitCommitInfo;
588
589    #[test]
590    fn test_git_commit_info_with_formatted_fields() {
591        let json = r#"{
592            "id":"abc123",
593            "authors":[{"user":"u","name":"User","email":"u@x"}],
594            "date":"2025-01-01T00:00:00Z",
595            "title":"feat: add thing",
596            "message":"feat: add thing\n\nLong body.",
597            "formattedTitle":"<p>feat: add thing</p>",
598            "formattedMessage":"<p>feat: add thing</p><p>Long body.</p>",
599            "parents":["p1"]
600        }"#;
601        let info: GitCommitInfo = serde_json::from_str(json).unwrap();
602        assert_eq!(info.formatted_title.as_deref(), Some("<p>feat: add thing</p>"));
603        assert_eq!(info.formatted_message.as_deref(), Some("<p>feat: add thing</p><p>Long body.</p>"));
604        assert_eq!(info.parents, vec!["p1"]);
605    }
606
607    #[test]
608    fn test_git_commit_info_without_formatted_fields() {
609        let json = r#"{"id":"abc","authors":[],"date":null,"title":"t","message":"m"}"#;
610        let info: GitCommitInfo = serde_json::from_str(json).unwrap();
611        assert!(info.formatted_title.is_none());
612        assert!(info.formatted_message.is_none());
613    }
614}