nickel_lang_git/
lib.rs

1//! A small crate providing a convienient interface to the git operations
2//! that nickel uses.
3
4use anyhow::anyhow;
5use gix::{
6    ObjectId,
7    interrupt::IS_INTERRUPTED,
8    progress::Discard,
9    remote::{self, Direction, fetch, fetch::refmap},
10    worktree::state::checkout,
11};
12use std::{num::NonZero, path::Path};
13
14/// An error that occurred during a git operation.
15#[derive(thiserror::Error, Debug)]
16pub enum Error {
17    #[error("io error {error} at {path}")]
18    Io {
19        error: std::io::Error,
20        path: std::path::PathBuf,
21    },
22
23    #[error("target `{target}` not found in `{url}`")]
24    TargetNotFound { url: Box<gix::Url>, target: Target },
25
26    #[error("{0:?}")]
27    Internal(#[from] anyhow::Error),
28}
29
30pub type Result<T, E = Error> = std::result::Result<T, E>;
31
32trait IoResultExt<T> {
33    fn with_path<P: AsRef<Path>>(self, path: P) -> Result<T>;
34}
35
36impl<T> IoResultExt<T> for Result<T, std::io::Error> {
37    fn with_path<P: AsRef<Path>>(self, path: P) -> Result<T> {
38        self.map_err(|error| Error::Io {
39            error,
40            path: path.as_ref().to_owned(),
41        })
42    }
43}
44
45trait InternalResultExt<T> {
46    fn wrap_err(self) -> Result<T>;
47}
48
49impl<T, E: Into<anyhow::Error>> InternalResultExt<T> for Result<T, E> {
50    fn wrap_err(self) -> Result<T> {
51        self.map_err(|e| Error::Internal(e.into()))
52    }
53}
54
55/// Specifies a git location that we can fetch.
56#[derive(Clone, Debug, PartialEq, Eq, Hash)]
57pub struct Spec {
58    /// The url of a git repository.
59    pub url: gix::Url,
60    /// Specifies the target commit within the repository.
61    pub target: Target,
62}
63
64impl Spec {
65    /// The HEAD commit at the given url.
66    pub fn head(url: gix::Url) -> Self {
67        Self {
68            url,
69            target: Target::Head,
70        }
71    }
72
73    pub fn commit(url: gix::Url, commit: ObjectId) -> Self {
74        Self {
75            url,
76            target: Target::Commit(commit),
77        }
78    }
79}
80
81/// The different kinds of git "thing" that we can target.
82#[serde_with::serde_as]
83#[derive(
84    Clone,
85    Debug,
86    PartialEq,
87    Eq,
88    Hash,
89    Default,
90    serde::Serialize,
91    serde::Deserialize,
92    PartialOrd,
93    Ord,
94)]
95pub enum Target {
96    /// By default, we target the remote HEAD.
97    #[default]
98    Head,
99    /// Target the tip of a specific branch.
100    Branch(String),
101    /// Target a specific tag.
102    Tag(String),
103    /// Target a specific commit.
104    ///
105    /// Currently, we only support a full commit: this needs to be a full hex-encoded
106    /// sha hash. We could try to support prefixes also, but it requires some work
107    /// and it *appears* to be inherently less efficient because there doesn't seem
108    /// to be a way to fetch it in one shot. At least, when `cargo` needs to fetch an
109    /// abbreviated hash it fetches everything and then looks for the hash among the
110    /// things that it finds.
111    Commit(#[serde_as(as = "serde_with::DisplayFromStr")] ObjectId),
112}
113
114impl std::fmt::Display for Target {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            Target::Head => write!(f, "HEAD"),
118            Target::Branch(branch) => write!(f, "refs/heads/{branch}"),
119            Target::Tag(tag) => write!(f, "refs/tags/{tag}"),
120            Target::Commit(c) => write!(f, "{c}"),
121        }
122    }
123}
124
125fn source_object_id(source: &refmap::Source) -> Result<ObjectId> {
126    match source {
127        refmap::Source::ObjectId(id) => Ok(*id),
128        refmap::Source::Ref(r) => {
129            let (_name, id, peeled) = r.unpack();
130
131            Ok(peeled
132                .or(id)
133                .ok_or_else(|| anyhow!("unborn reference"))?
134                .to_owned())
135        }
136    }
137}
138
139/// Fetches the contents of a git repository into directory.
140///
141/// The directory will be created if it doesn't exist yet. The data will be
142/// fetched from scratch, even if it has already been fetched before. However,
143/// we will try to minimize the amount of data to fetch (for example, by doing a
144/// shallow fetch).
145///
146/// Only the contents of the git repository will be written to the given
147/// directory; the git directory itself will be discarded.
148pub fn fetch(spec: &Spec, dir: impl AsRef<Path>) -> Result<ObjectId> {
149    let dir = dir.as_ref();
150    std::fs::create_dir_all(dir).with_path(dir)?;
151
152    // Fetch the git directory somewhere temporary.
153    let git_tempdir = tempfile::tempdir().wrap_err()?;
154    let repo = gix::init(git_tempdir.path()).wrap_err()?;
155    let refspec = spec.target.to_string();
156
157    let remote = repo
158        .remote_at(spec.url.clone())
159        .wrap_err()?
160        .with_fetch_tags(fetch::Tags::None)
161        .with_refspecs(Some(refspec.as_str()), Direction::Fetch)
162        .wrap_err()?;
163
164    // This does similar credentials stuff to the git CLI (e.g. it looks for ssh
165    // keys if it's a fetch over ssh, or it tries to run `askpass` if it needs
166    // credentials for https). Maybe we want to have explicit credentials
167    // configuration instead of or in addition to the default?
168    let connection = remote.connect(Direction::Fetch).wrap_err()?;
169    let outcome = connection
170        .prepare_fetch(&mut Discard, remote::ref_map::Options::default())
171        .wrap_err()?
172        // For now, we always fetch shallow. Maybe for the index it's more efficient to
173        // keep a single repo around and update it? But that might be in another method.
174        .with_shallow(fetch::Shallow::DepthAtRemote(NonZero::new(1).unwrap()))
175        .receive(&mut Discard, &IS_INTERRUPTED)
176        .map_err(|e| match e {
177            fetch::Error::NoMapping { .. } => Error::TargetNotFound {
178                url: Box::new(spec.url.clone()),
179                target: spec.target.clone(),
180            },
181            // This is the error we get back if we ask for a commit that they don't have.
182            fetch::Error::Fetch(gix::protocol::fetch::Error::FetchResponse(
183                gix::protocol::fetch::response::Error::UnknownSectionHeader { .. },
184            )) => Error::TargetNotFound {
185                url: Box::new(spec.url.clone()),
186                target: spec.target.clone(),
187            },
188            _ => Error::Internal(e.into()),
189        })?;
190
191    if outcome.ref_map.mappings.len() > 1 {
192        return Err(anyhow!("we only asked for 1 ref; why did we get more?")).wrap_err();
193    }
194    if outcome.ref_map.mappings.is_empty() {
195        return Err(Error::TargetNotFound {
196            url: Box::new(spec.url.clone()),
197            target: spec.target.clone(),
198        });
199    }
200    let object_id = source_object_id(&outcome.ref_map.mappings[0].remote)?;
201
202    let object = repo.find_object(object_id).wrap_err()?;
203    let tree_id = object.peel_to_tree().wrap_err()?.id();
204    let mut index = repo.index_from_tree(&tree_id).wrap_err()?;
205
206    checkout(
207        &mut index,
208        dir,
209        repo.objects.clone(),
210        &Discard,
211        &Discard,
212        &IS_INTERRUPTED,
213        checkout::Options {
214            overwrite_existing: true,
215            ..Default::default()
216        },
217    )
218    .wrap_err()?;
219    index.write(Default::default()).wrap_err()?;
220
221    Ok(tree_id.detach())
222}