-
Notifications
You must be signed in to change notification settings - Fork 95
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
16 changed files
with
786 additions
and
535 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
[package] | ||
name = "nickel-lang-git" | ||
description = "Git utility functions for internal use in nickel" | ||
version = "0.1.0" | ||
edition = "2021" | ||
|
||
[dependencies] | ||
anyhow.workspace = true | ||
gix = { workspace = true, features = ["blocking-network-client"] } | ||
tempfile.workspace = true | ||
thiserror.workspace = true | ||
|
||
[dev-dependencies] | ||
anyhow = { version = "1.0.86", features = ["backtrace"] } | ||
clap = { version = "4.5.16", features = ["derive"] } | ||
gix = { version = "0.66.0", features = ["blocking-http-transport-reqwest-rust-tls"] } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
use std::path::PathBuf; | ||
|
||
use clap::{ArgGroup, Parser}; | ||
use nickel_lang_git::{Spec, Target}; | ||
|
||
#[derive(Parser)] | ||
#[command(group = ArgGroup::new("target").args(["branch", "tag", "commit"]))] | ||
struct Args { | ||
repository: String, | ||
|
||
directory: PathBuf, | ||
|
||
#[arg(long)] | ||
branch: Option<String>, | ||
|
||
#[arg(long)] | ||
tag: Option<String>, | ||
|
||
#[arg(long)] | ||
commit: Option<String>, | ||
} | ||
|
||
pub fn main() -> anyhow::Result<()> { | ||
let args = Args::parse(); | ||
|
||
let target = if let Some(branch) = args.branch { | ||
Target::Branch(branch) | ||
} else if let Some(tag) = args.tag { | ||
Target::Tag(tag) | ||
} else if let Some(commit) = args.commit { | ||
Target::Commit(commit) | ||
} else { | ||
Target::Default | ||
}; | ||
|
||
let spec = Spec { | ||
url: args.repository.try_into()?, | ||
target, | ||
}; | ||
|
||
nickel_lang_git::fetch(&spec, args.directory)?; | ||
Ok(()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,187 @@ | ||
use anyhow::anyhow; | ||
use gix::{objs::Kind, ObjectId}; | ||
use std::{num::NonZero, path::Path}; | ||
|
||
#[derive(thiserror::Error, Debug)] | ||
pub enum Error { | ||
#[error("io error {error} at {path}")] | ||
Io { | ||
error: std::io::Error, | ||
path: std::path::PathBuf, | ||
}, | ||
|
||
#[error("target `{0}` not found")] | ||
TargetNotFound(Target), | ||
|
||
#[error(transparent)] | ||
Internal(#[from] anyhow::Error), | ||
} | ||
|
||
pub type Result<T, E = Error> = std::result::Result<T, E>; | ||
|
||
trait IoResultExt<T> { | ||
fn with_path<P: AsRef<Path>>(self, path: P) -> Result<T>; | ||
} | ||
|
||
impl<T> IoResultExt<T> for Result<T, std::io::Error> { | ||
fn with_path<P: AsRef<Path>>(self, path: P) -> Result<T> { | ||
self.map_err(|error| Error::Io { | ||
error, | ||
path: path.as_ref().to_owned(), | ||
}) | ||
} | ||
} | ||
|
||
trait InternalResultExt<T> { | ||
fn wrap_err(self) -> Result<T>; | ||
} | ||
|
||
impl<T, E: Into<anyhow::Error>> InternalResultExt<T> for Result<T, E> { | ||
fn wrap_err(self) -> Result<T> { | ||
self.map_err(|e| Error::Internal(e.into())) | ||
} | ||
} | ||
|
||
#[derive(Clone, Debug, PartialEq, Eq, Hash)] | ||
pub struct Spec { | ||
pub url: gix::Url, | ||
pub target: Target, | ||
} | ||
|
||
/// The different kinds of git "thing" that we can target. | ||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)] | ||
pub enum Target { | ||
/// By default, we target the remote HEAD. | ||
#[default] | ||
Default, | ||
/// Target the tip of a specific branch. | ||
Branch(String), | ||
/// Target a specific tag. | ||
Tag(String), | ||
/// Target a specific commit. | ||
/// | ||
/// Currently, we only support a full commit: this needs to be a full hex-encoded | ||
/// sha hash. We could try to support prefixes also, but it requires some work | ||
/// and it *appears* to be inherently less efficient because there doesn't seem | ||
/// to be a way to fetch it in one shot. At least, when `cargo` needs to fetch an | ||
/// abbreviated hash it fetches everything and then looks for the hash among the | ||
/// things that it finds. | ||
Commit(String), | ||
} | ||
|
||
impl std::fmt::Display for Target { | ||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
match self { | ||
Target::Default => write!(f, "HEAD"), | ||
Target::Branch(branch) => write!(f, "refs/heads/{branch}"), | ||
Target::Tag(tag) => write!(f, "refs/tags/{tag}"), | ||
Target::Commit(c) => write!(f, "{}", c), | ||
} | ||
} | ||
} | ||
|
||
impl Target { | ||
fn refspec(&self) -> String { | ||
self.to_string() | ||
} | ||
} | ||
|
||
fn source_object_id(source: &gix::remote::fetch::Source) -> Result<ObjectId> { | ||
match source { | ||
gix::remote::fetch::Source::ObjectId(id) => Ok(*id), | ||
gix::remote::fetch::Source::Ref(r) => { | ||
let (_name, id, peeled) = r.unpack(); | ||
|
||
Ok(peeled | ||
.or(id) | ||
.ok_or_else(|| anyhow!("unborn reference"))? | ||
.to_owned()) | ||
} | ||
} | ||
} | ||
|
||
/// Fetches the contents of a git repository into a target directory. | ||
/// | ||
/// The directory will be created if it doesn't exist yet. The data will be | ||
/// fetched from scratch, even if it has already been fetched before. However, | ||
/// we will try to minimize the amount of data to fetch (for example, by doing a | ||
/// shallow fetch). | ||
/// | ||
/// Only the contents of the git repository will be written to the given | ||
/// directory; the git directory itself will be discarded. | ||
pub fn fetch(spec: &Spec, dir: impl AsRef<Path>) -> Result<ObjectId> { | ||
let dir = dir.as_ref(); | ||
let parent = dir.parent().ok_or(anyhow!("no parent"))?; | ||
|
||
// Fetch the git directory somewhere temporary. | ||
let git_tempdir = tempfile::tempdir().wrap_err()?; | ||
let repo = gix::init(git_tempdir.path()).wrap_err()?; | ||
let refspec = spec.target.refspec(); | ||
|
||
let remote = repo | ||
.remote_at(spec.url.clone()) | ||
.wrap_err()? | ||
.with_fetch_tags(gix::remote::fetch::Tags::None) | ||
.with_refspecs(Some(refspec.as_str()), gix::remote::Direction::Fetch) | ||
.wrap_err()?; | ||
|
||
// This does similar credentials stuff to the git CLI (e.g. it looks for ssh | ||
// keys if it's a fetch over ssh, or it tries to run `askpass` if it needs | ||
// credentials for https). Maybe we want to have explicit credentials | ||
// configuration instead of or in addition to the default? | ||
let connection = remote.connect(gix::remote::Direction::Fetch).wrap_err()?; | ||
let outcome = connection | ||
.prepare_fetch( | ||
&mut gix::progress::Discard, | ||
gix::remote::ref_map::Options::default(), | ||
) | ||
.wrap_err()? | ||
// For now, we always fetch shallow. Maybe for the index it's more efficient to | ||
// keep a single repo around and update it? But that might be in another method. | ||
.with_shallow(gix::remote::fetch::Shallow::DepthAtRemote( | ||
NonZero::new(1).unwrap(), | ||
)) | ||
.receive(&mut gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED) | ||
.map_err(|e| match e { | ||
gix::remote::fetch::Error::NoMapping { .. } => { | ||
Error::TargetNotFound(spec.target.clone()) | ||
} | ||
_ => Error::Internal(e.into()), | ||
})?; | ||
|
||
if outcome.ref_map.mappings.len() > 1 { | ||
return Err(anyhow!("we only asked for 1 ref; why did we get more?")).wrap_err(); | ||
} | ||
if outcome.ref_map.mappings.is_empty() { | ||
return Err(Error::TargetNotFound(spec.target.clone())); | ||
} | ||
let object_id = source_object_id(&outcome.ref_map.mappings[0].remote)?; | ||
|
||
let object = repo.find_object(object_id).wrap_err()?; | ||
let commit = object.clone().peel_to_kind(Kind::Commit).wrap_err()?; | ||
let tree_id = object.peel_to_tree().wrap_err()?.id(); | ||
let mut index = repo.index_from_tree(&tree_id).wrap_err()?; | ||
|
||
let tree_tempdir = tempfile::tempdir_in(parent).wrap_err()?; | ||
std::fs::create_dir_all(&tree_tempdir).with_path(&tree_tempdir)?; | ||
|
||
gix::worktree::state::checkout( | ||
&mut index, | ||
tree_tempdir.path(), | ||
repo.objects.clone(), | ||
&gix::progress::Discard, | ||
&gix::progress::Discard, | ||
&gix::interrupt::IS_INTERRUPTED, | ||
gix::worktree::state::checkout::Options { | ||
overwrite_existing: true, | ||
..Default::default() | ||
}, | ||
) | ||
.wrap_err()?; | ||
index.write(Default::default()).wrap_err()?; | ||
|
||
// Move the checked-out repo to the right place. | ||
std::fs::rename(tree_tempdir.path(), dir).with_path(dir)?; | ||
|
||
Ok(commit.id) | ||
} |
Oops, something went wrong.