stelae/utils/
git.rs

1//! The git module contains structs for interacting with git repositories
2//! in the Stelae Archive.
3use crate::utils::paths::clean_path;
4use anyhow::Context as _;
5use git2::{Commit, Repository, Sort};
6use std::{
7    fmt,
8    path::{Path, PathBuf},
9};
10
11/// This is the first step towards having custom errors
12pub const GIT_REQUEST_NOT_FOUND: &str = "Git object doesn't exist";
13
14/// Represents a git repository within an oll archive. includes helpers for
15/// for interacting with the Git Repo.
16/// Expects a path to the archive, as well as the repo's organization and name.
17pub struct Repo {
18    /// Path to the archive
19    pub archive_path: String,
20    /// Path to the Stele
21    pub path: PathBuf,
22    /// Repo organization
23    pub org: String,
24    /// Repo name
25    pub name: String,
26    /// git2 repository pointing to the repo in the archive.
27    pub repo: Repository,
28}
29
30/// Represents a git blob returned from the archive on disk
31#[derive(Debug)]
32pub struct Blob {
33    /// The actual content of the git blob
34    pub content: Vec<u8>,
35    /// Path to the blob
36    pub path: String,
37}
38
39impl fmt::Debug for Repo {
40    fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
41        write!(
42            formatter,
43            "Repo for {}/{} in the archive at {}",
44            self.org, self.name, self.archive_path
45        )
46    }
47}
48
49#[expect(
50    clippy::missing_trait_methods,
51    clippy::unwrap_used,
52    reason = "Expect to have git repo on disk"
53)]
54impl Clone for Repo {
55    fn clone(&self) -> Self {
56        Self {
57            archive_path: self.archive_path.clone(),
58            org: self.org.clone(),
59            name: self.name.clone(),
60            path: self.path.clone(),
61            repo: Repository::open(self.path.clone()).unwrap(),
62        }
63    }
64}
65
66impl Repo {
67    /// Find something like `abc123:/path/to/something.txt` in the Git repo
68    fn find(&self, query: &str) -> anyhow::Result<Vec<u8>> {
69        tracing::trace!(query, "Git reverse parse search");
70        let obj = self.repo.revparse_single(query)?;
71        let blob = obj.as_blob().context("Couldn't cast Git object to blob")?;
72        Ok(blob.content().to_owned())
73    }
74
75    /// Do the work of looking for the requested Git object.
76    ///
77    ///
78    /// # Errors
79    /// Will error if the Repo couldn't be found, or if there was a problem with the Git object.
80    pub fn find_blob(
81        archive_path: &Path,
82        namespace: &str,
83        name: &str,
84        remainder: &str,
85        commitish: &str,
86    ) -> anyhow::Result<Blob> {
87        let repo = Self::new(archive_path, namespace, name)?;
88        let blob_path = clean_path(remainder);
89        let blob = repo.get_bytes_at_path(commitish, &blob_path)?;
90        Ok(blob)
91    }
92    /// Create a new Repo object with helpers for interacting with a Git Repo.
93    /// Expects a path to the archive, as well as the repo's org and name.
94    ///
95    /// # Errors
96    ///
97    /// Will return `Err` if git repository does not exist at `{org}/{name}`
98    /// in archive, or if there is something wrong with the git repository.
99    pub fn new(archive_path: &Path, org: &str, name: &str) -> anyhow::Result<Self> {
100        let archive_path_str = archive_path.to_string_lossy();
101        tracing::trace!(org, name, "Creating new Repo at {archive_path_str}");
102        let repo_path = format!("{archive_path_str}/{org}/{name}");
103        Ok(Self {
104            archive_path: archive_path_str.into(),
105            org: org.into(),
106            name: name.into(),
107            path: PathBuf::from(repo_path.clone()),
108            repo: Repository::open(repo_path)?,
109        })
110    }
111
112    /// Create a new Repo object from a full path to the repo in the archive.
113    ///
114    /// # Errors
115    /// Will return `Err` if the path does not contain at least org and name,
116    /// or if git repository does not exist at `{org}/{name}` in archive, or
117    /// if there is something wrong with the git repository.
118    pub fn from_path(path: &Path) -> anyhow::Result<Self> {
119        let components: Vec<&str> = path
120            .components()
121            .filter_map(|component| component.as_os_str().to_str())
122            .collect();
123        if components.len() < 2 {
124            anyhow::bail!("Path must contain at least org and name");
125        }
126        let name = (*components
127            .last()
128            .ok_or_else(|| anyhow::anyhow!("Missing repo name"))?)
129        .to_owned();
130        let org = (*components
131            .get(components.len() - 2)
132            .ok_or_else(|| anyhow::anyhow!("Missing repo org"))?)
133        .to_owned();
134        let archive_path_slice = components.get(..components.len() - 2).ok_or_else(|| {
135            anyhow::anyhow!("Path does not contain enough components for archive_path")
136        })?;
137        let archive_path = archive_path_slice.iter().collect::<PathBuf>();
138        Self::new(&archive_path, &org, &name)
139    }
140
141    /// Returns bytes of blob found in the commit `commitish` at path `path`
142    /// if a blob is not found at path, it will try adding ".html", "index.html,
143    /// and "/index.html".
144    /// Example usage:
145    ///
146    //// let content: Vec<u8> = repo.get_bytes_at_path(
147    ////    "0f2f1ef9fa213dcf83e269bc832ab63435cbd4b1",
148    ////    "us/ca/cities/san-mateo"
149    //// );
150    ///
151    /// # Errors
152    ///
153    /// Will return `Err` if `commitish` does not exist in repo, if a blob does
154    /// not exist in commit at `path`, or if there is a problem with reading repo.
155    pub fn get_bytes_at_path(&self, commitish: &str, path: &str) -> anyhow::Result<Blob> {
156        let base_revision = format!("{commitish}:{path}");
157        for postfix in ["", "/index.html", ".html", "index.html"] {
158            let query = format!("{base_revision}{postfix}");
159            let blob = self.find(&query);
160            if let Ok(content) = blob {
161                let filepath = format!("{path}{postfix}");
162                tracing::trace!(query, "Found Git object");
163                return Ok(Blob {
164                    content,
165                    path: filepath,
166                });
167            }
168        }
169        tracing::debug!(base_revision, "Couldn't find requested Git object");
170        anyhow::bail!(GIT_REQUEST_NOT_FOUND)
171    }
172
173    /// Instantiates a git revwalk from the beginning of the repository.
174    /// Return an iterator over the commits.
175    ///
176    /// # Errors
177    /// Will error if the revwalk could not be instantiated
178    pub fn iter_commits(&self) -> anyhow::Result<impl Iterator<Item = Commit>> {
179        let mut revwalk = self.repo.revwalk()?;
180        revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?;
181        revwalk.push_head()?;
182        Ok(revwalk
183            .filter_map(|found_oid| {
184                let oid = found_oid.ok()?;
185                self.repo.find_commit(oid).ok()
186            })
187            .collect::<Vec<Commit>>()
188            .into_iter())
189    }
190}