use std::collections::{BTreeSet, HashMap};
use std::ffi::OsStr;
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use duct::Expression;
use eyre::{Result, WrapErr, eyre};
use gix::{self};
use once_cell::sync::OnceCell;
use xx::file;
use crate::cmd::CmdLineRunner;
use crate::config::Settings;
use crate::file::touch_dir;
use crate::ui::progress_report::SingleReport;
use std::ffi::OsString;
pub(crate) struct Git {
pub dir: PathBuf,
pub repo: OnceCell<gix::Repository>,
}
macro_rules! git_cmd {
( $dir:expr $(, $arg:expr )* $(,)? ) => {
{
let safe = format!("safe.directory={}", $dir.display());
sanitize_git_env(cmd!("git", "-c", $crate::git::github_credential_config("github.com"), "-c", $crate::git::github_credential_config("github.com:443"), "-C", $dir, "-c", safe, "-c", "core.autocrlf=false" $(, $arg)*))
}
}
}
macro_rules! git_cmd_read {
( $dir:expr $(, $arg:expr )* $(,)? ) => {
{
git_cmd!($dir $(, $arg)*).read().wrap_err_with(|| {
let args = [$($arg,)*].join(" ");
format!("git {args} failed")
})
}
}
}
impl Git {
pub(crate) fn new<P: AsRef<Path>>(dir: P) -> Self {
Self {
dir: dir.as_ref().to_path_buf(),
repo: OnceCell::new(),
}
}
pub(crate) fn repo(&self) -> Result<&gix::Repository> {
self.repo.get_or_try_init(|| {
trace!("opening git repository via gix at {:?}", self.dir);
gix::open(&self.dir)
.wrap_err_with(|| format!("failed to open git repository at {:?}", self.dir))
.inspect_err(|err| warn!("{err:#}"))
})
}
pub(crate) fn is_repo(&self) -> bool {
self.dir.join(".git").is_dir()
}
pub(crate) fn update(&self, gitref: Option<String>) -> Result<(String, String)> {
match gitref {
Some(gitref) => {
let remote_ref_kind = self.remote_ref_kind(&gitref)?;
self.update_ref(gitref, remote_ref_kind)
}
None => self.update_ref(self.current_branch()?, None),
}
}
pub(crate) fn update_tag(&self, gitref: String) -> Result<(String, String)> {
self.update_ref(gitref, Some(RemoteRefKind::Tag))
}
fn remote_ref_kind(&self, gitref: &str) -> Result<Option<RemoteRefKind>> {
if gitref.starts_with("refs/") || looks_like_sha(gitref) {
return Ok(None);
}
let branch_ref = format!("refs/heads/{gitref}");
let tag_ref = format!("refs/tags/{gitref}");
let output = git_cmd_read!(
&self.dir,
"ls-remote",
"--refs",
"origin",
&branch_ref,
&tag_ref
)?;
Ok(remote_ref_kind(&output, &branch_ref, &tag_ref))
}
fn checkout(&self, gitref: &str) -> Result<()> {
let cmd = git_cmd!(
&self.dir,
"-c",
"advice.detachedHead=false",
"-c",
"advice.objectNameWarning=false",
"checkout",
"--force",
gitref,
);
let res = cmd
.stderr_to_stdout()
.stdout_capture()
.unchecked()
.run()
.map_err(|err| eyre!("git failed: {cmd:?} {err:#}"))?;
if !res.status.success() {
return Err(eyre!(
"git failed: {cmd:?} {}",
String::from_utf8_lossy(&res.stdout)
));
}
touch_dir(&self.dir)?;
Ok(())
}
fn update_ref(
&self,
gitref: String,
remote_ref_kind: Option<RemoteRefKind>,
) -> Result<(String, String)> {
debug!("updating {} to {}", self.dir.display(), gitref);
let exec = |cmd: Expression| match cmd.stderr_to_stdout().stdout_capture().unchecked().run()
{
Ok(res) => {
if res.status.success() {
Ok(())
} else {
Err(eyre!(
"git failed: {cmd:?} {}",
String::from_utf8(res.stdout).unwrap()
))
}
}
Err(err) => Err(eyre!("git failed: {cmd:?} {err:#}")),
};
debug!("updating {} to {} with git", self.dir.display(), gitref);
let qualified_ref = remote_ref_kind.map(|kind| qualify_remote_ref(&gitref, kind));
let refspec = qualified_ref
.as_ref()
.map_or_else(|| format!("{gitref}:{gitref}"), |r| format!("{r}:{r}"));
exec(git_cmd!(
&self.dir,
"fetch",
"--prune",
"--update-head-ok",
"origin",
&refspec
))?;
let prev_rev = self.current_sha()?;
let checkout_ref = match (remote_ref_kind, qualified_ref.as_deref()) {
(Some(RemoteRefKind::Tag), Some(tag_ref)) => tag_ref,
_ => &gitref,
};
exec(git_cmd!(
&self.dir,
"-c",
"advice.detachedHead=false",
"-c",
"advice.objectNameWarning=false",
"checkout",
"--force",
&checkout_ref
))?;
let post_rev = self.current_sha()?;
touch_dir(&self.dir)?;
Ok((prev_rev, post_rev))
}
pub(crate) fn clone(&self, url: &str, options: CloneOptions) -> Result<()> {
if let Some(parent) = self.dir.parent() {
file::mkdirp(parent)?;
}
let sha_branch = options.branch.as_deref().filter(|b| looks_like_sha(b));
let revision = options.revision.as_deref().or(sha_branch);
let named_branch = options
.branch
.as_deref()
.filter(|b| !looks_like_sha(b) && revision.is_none());
if (Settings::get().libgit2 || Settings::get().gix)
&& std::env::var_os("MISE_GITHUB_RELAY_SOCKET").is_none()
{
debug!("cloning {} to {} with gix", url, self.dir.display());
let mut prepare_clone = gix::prepare_clone(url, &self.dir)?
.with_in_memory_config_overrides([
github_credential_config("github.com"),
github_credential_config("github.com:443"),
]);
if let Some(branch) = named_branch {
prepare_clone = prepare_clone.with_ref_name(Some(branch))?;
}
let (mut prepare_checkout, _) = prepare_clone
.fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
prepare_checkout
.main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
if let Some(revision) = revision {
self.checkout(revision)?;
}
return Ok(());
}
debug!("cloning {} to {} with git", url, self.dir.display());
match get_git_version() {
Ok(version) => trace!("git version: {}", version),
Err(err) => warn!(
"failed to get git version: {:#}\n Git is required to use mise.",
err
),
}
if let Some(pr) = &options.pr {
pr.abandon();
}
let mut cmd = sanitize_git_cmd_runner(
CmdLineRunner::new("git")
.arg("-c")
.arg(github_credential_config("github.com"))
.arg("-c")
.arg(github_credential_config("github.com:443"))
.arg("clone")
.arg("-q")
.arg("-o")
.arg("origin")
.arg("-c")
.arg("core.autocrlf=false"),
);
if revision.is_none() {
cmd = cmd.arg("--depth").arg("1");
}
cmd = cmd.arg(url).arg(&self.dir);
if let Some(branch) = named_branch {
cmd = cmd.args([
"-b",
branch,
"--single-branch",
"-c",
"advice.detachedHead=false",
]);
}
cmd.execute()?;
if let Some(revision) = revision {
self.checkout(revision)?;
}
Ok(())
}
pub(crate) fn update_submodules(&self) -> Result<()> {
debug!("updating submodules in {}", self.dir.display());
let exec = |cmd: Expression| match cmd.stderr_to_stdout().stdout_capture().unchecked().run()
{
Ok(res) => {
if res.status.success() {
Ok(())
} else {
Err(eyre!(
"git failed: {cmd:?} {}",
String::from_utf8(res.stdout).unwrap()
))
}
}
Err(err) => Err(eyre!("git failed: {cmd:?} {err:#}")),
};
exec(
git_cmd!(&self.dir, "submodule", "update", "--init", "--recursive")
.env("GIT_TERMINAL_PROMPT", "0"),
)?;
Ok(())
}
pub(crate) fn current_branch(&self) -> Result<String> {
let dir = &self.dir;
if let Ok(repo) = self.repo() {
let head = repo.head()?;
let branch = head
.referent_name()
.map(|name| name.shorten().to_string())
.unwrap_or_else(|| head.id().unwrap().to_string());
debug!("current branch for {dir:?}: {branch}");
return Ok(branch);
}
let branch = git_cmd_read!(&self.dir, "branch", "--show-current")?;
debug!("current branch for {}: {}", self.dir.display(), &branch);
Ok(branch)
}
pub(crate) fn current_sha(&self) -> Result<String> {
let dir = &self.dir;
if let Ok(repo) = self.repo() {
let head = repo.head()?;
let sha = head
.id()
.ok_or_else(|| eyre::eyre!("repository {} has no commit at HEAD", dir.display()))?
.to_string();
debug!("current sha for {dir:?}: {sha}");
return Ok(sha);
}
let sha = git_cmd_read!(&self.dir, "rev-parse", "HEAD")?;
debug!("current sha for {}: {}", self.dir.display(), &sha);
Ok(sha)
}
pub(crate) fn current_sha_short(&self) -> Result<String> {
let dir = &self.dir;
if let Ok(repo) = self.repo() {
let head = repo.head()?;
let id = head.id();
let sha = id.unwrap().to_string()[..7].to_string();
debug!("current sha for {dir:?}: {sha}");
return Ok(sha);
}
let sha = git_cmd_read!(&self.dir, "rev-parse", "--short", "HEAD")?;
debug!("current sha for {dir:?}: {sha}");
Ok(sha)
}
pub(crate) fn current_abbrev_ref(&self) -> Result<String> {
let dir = &self.dir;
if let Ok(repo) = self.repo() {
let head = repo.head()?;
let head = head.name().shorten().to_string();
debug!("current abbrev ref for {dir:?}: {head}");
return Ok(head);
}
let aref = git_cmd_read!(&self.dir, "rev-parse", "--abbrev-ref", "HEAD")?;
debug!("current abbrev ref for {}: {}", self.dir.display(), &aref);
Ok(aref)
}
pub(crate) fn get_remote_url(&self) -> Option<String> {
let dir = &self.dir;
if !self.exists() {
return None;
}
if let Ok(repo) = self.repo()
&& let Ok(remote) = repo.find_remote("origin")
&& let Some(url) = remote.url(gix::remote::Direction::Fetch)
{
trace!("remote url for {dir:?}: {url}");
return Some(url.to_string());
}
let res = git_cmd_read!(&self.dir, "config", "--get", "remote.origin.url");
match res {
Ok(url) => {
debug!("remote url for {dir:?}: {url}");
Some(url)
}
Err(err) => {
warn!("failed to get remote url for {dir:?}: {err:#}");
None
}
}
}
pub(crate) fn split_url_and_ref(url: &str) -> (String, Option<String>) {
match url.split_once('#') {
Some((url, _ref)) => (url.to_string(), Some(_ref.to_string())),
None => (url.to_string(), None),
}
}
pub(crate) fn remote_sha(&self, branch: &str) -> Result<Option<String>> {
let output = git_cmd_read!(&self.dir, "ls-remote", "origin", branch)?;
Ok(output
.lines()
.next()
.and_then(|line| line.split_whitespace().next())
.map(|sha| sha.to_string()))
}
pub(crate) fn exists(&self) -> bool {
self.dir.join(".git").is_dir()
}
pub(crate) fn get_root() -> eyre::Result<PathBuf> {
Ok(cmd!("git", "rev-parse", "--show-toplevel")
.read()?
.trim()
.into())
}
pub(crate) fn changed_paths(&self, base: &str, head: &str) -> Result<BTreeSet<PathBuf>> {
validate_revisions(base, head)?;
let range = format!("{base}...{head}");
let output = git_cmd!(
&self.dir,
"diff",
"--name-only",
"-z",
"--no-renames",
"--relative",
&range,
"--",
"."
)
.stdout_capture()
.run()
.wrap_err_with(|| format!("git diff for {range} failed"))?;
output
.stdout
.split(|byte| *byte == 0)
.filter(|path| !path.is_empty())
.map(path_from_git_bytes)
.collect()
}
pub(crate) fn merge_base(&self, base: &str, head: &str) -> Result<String> {
validate_revisions(base, head)?;
Ok(git_cmd_read!(&self.dir, "merge-base", "--", base, head)?
.trim()
.to_string())
}
pub(crate) fn file_at_revision(&self, revision: &str, path: &Path) -> Result<Option<String>> {
validate_revision("revision", revision)?;
let Some(path) = path.to_str() else {
return Ok(None);
};
let object = format!("{revision}:{}", path.replace('\\', "/"));
let output = git_cmd!(&self.dir, "show", "--no-textconv", &object)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()
.wrap_err_with(|| format!("git show for {object:?} failed"))?;
if !output.status.success() {
return Ok(None);
}
Ok(Some(String::from_utf8(output.stdout).wrap_err_with(
|| format!("Git file {path:?} at {revision:?} is not UTF-8"),
)?))
}
pub(crate) fn get_path<P: AsRef<Path>>(path: P) -> eyre::Result<PathBuf> {
let root = Self::get_root()?;
let path = cmd!("git", "-C", &root, "rev-parse", "--git-path", path.as_ref()).read()?;
let path = PathBuf::from(path.trim());
Ok(if path.is_absolute() {
path
} else {
root.join(path)
})
}
}
fn validate_revisions(base: &str, head: &str) -> Result<()> {
validate_revision("base", base)?;
validate_revision("head", head)
}
fn validate_revision(name: &str, revision: &str) -> Result<()> {
if revision.is_empty() || revision.starts_with('-') || revision.contains('\0') {
return Err(eyre!("invalid Git {name} revision {revision:?}"));
}
Ok(())
}
fn path_from_git_bytes(path: &[u8]) -> Result<PathBuf> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
Ok(OsString::from_vec(path.to_vec()).into())
}
#[cfg(not(unix))]
{
Ok(String::from_utf8(path.to_vec())
.wrap_err("Git returned a non-UTF-8 path")?
.into())
}
}
pub(crate) fn github_credential_config(host: &str) -> String {
let executable = crate::env::MISE_BIN.to_string_lossy();
#[cfg(windows)]
let executable = std::borrow::Cow::Owned(executable.replace('\\', "/"));
let executable = shell_escape::unix::escape(executable);
format!("credential.https://{host}.helper=!{executable} token github --git-credential")
}
fn get_git_version() -> Result<String> {
let version = cmd!("git", "--version").read()?;
Ok(version.trim().into())
}
fn sanitize_git_env(cmd: Expression) -> Expression {
GIT_CONTEXT_ENV
.iter()
.fold(cmd, |cmd, env| cmd.env_remove(env))
}
fn sanitize_git_cmd_runner<'a>(cmd: CmdLineRunner<'a>) -> CmdLineRunner<'a> {
GIT_CONTEXT_ENV
.iter()
.fold(cmd, |cmd, env| cmd.env_remove(env))
}
pub(crate) fn sanitize_git_command(cmd: &mut std::process::Command) {
for env in GIT_CONTEXT_ENV {
cmd.env_remove(env);
}
}
const GIT_CONTEXT_ENV: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_NAMESPACE",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RemoteRefKind {
Branch,
Tag,
}
fn qualify_remote_ref(gitref: &str, kind: RemoteRefKind) -> String {
let prefix = match kind {
RemoteRefKind::Branch => "refs/heads/",
RemoteRefKind::Tag => "refs/tags/",
};
if gitref.starts_with(prefix) {
gitref.to_string()
} else {
format!("{prefix}{gitref}")
}
}
fn remote_ref_kind(output: &str, branch_ref: &str, tag_ref: &str) -> Option<RemoteRefKind> {
let has_ref = |expected: &str| {
output.lines().any(|line| {
line.split_once(char::is_whitespace)
.is_some_and(|(_, name)| name == expected)
})
};
if has_ref(branch_ref) {
Some(RemoteRefKind::Branch)
} else if has_ref(tag_ref) {
Some(RemoteRefKind::Tag)
} else {
None
}
}
fn looks_like_sha(s: &str) -> bool {
matches!(s.len(), 40 | 64) && s.bytes().all(|b| b.is_ascii_hexdigit())
}
pub(crate) fn main_checkout_equivalent(path: &Path) -> Option<PathBuf> {
static CACHE: LazyLock<Mutex<HashMap<PathBuf, Option<PathBuf>>>> =
LazyLock::new(Default::default);
for wt_root in path.ancestors() {
let dotgit = wt_root.join(".git");
if dotgit.is_dir() {
return None;
}
if dotgit.is_file() {
let main_root = CACHE
.lock()
.unwrap()
.entry(wt_root.to_path_buf())
.or_insert_with(|| main_checkout_root(&dotgit))
.clone();
if let Some(main_root) = main_root {
let equiv = main_root.join(path.strip_prefix(wt_root).ok()?);
return (equiv != path).then_some(equiv);
}
}
}
None
}
fn main_checkout_root(dotgit_file: &Path) -> Option<PathBuf> {
let contents = std::fs::read_to_string(dotgit_file).ok()?;
let gitdir = PathBuf::from(contents.strip_prefix("gitdir:")?.trim());
let gitdir = if gitdir.is_relative() {
dotgit_file.parent()?.join(gitdir)
} else {
gitdir
};
if gitdir.parent()?.file_name() != Some(OsStr::new("worktrees")) {
return None;
}
let common = PathBuf::from(
std::fs::read_to_string(gitdir.join("commondir"))
.ok()?
.trim(),
);
let common = if common.is_relative() {
gitdir.join(common)
} else {
common
};
let common = common.canonicalize().ok()?;
if common.file_name() == Some(OsStr::new(".git")) {
common.parent().map(|p| p.to_path_buf())
} else {
None }
}
pub(crate) fn plumbing_binary() -> Option<&'static Path> {
static BIN: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
let git = crate::file::which_spawnable("git")?;
if cfg!(target_os = "macos") && git == Path::new("/usr/bin/git") {
let installed = std::process::Command::new("xcode-select")
.arg("-p")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success());
if !installed {
return None;
}
}
Some(git)
});
BIN.as_deref()
}
#[derive(Debug, Default)]
pub(crate) struct PlumbingCall<'a> {
pub args: Vec<OsString>,
pub work_tree: Option<&'a Path>,
pub index_file: Option<&'a Path>,
pub cwd: Option<&'a Path>,
pub stdin: Option<&'a [u8]>,
}
impl<'a> PlumbingCall<'a> {
pub(crate) fn new<I, S>(args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
Self {
args: args.into_iter().map(Into::into).collect(),
..Default::default()
}
}
pub(crate) fn work_tree(mut self, path: &'a Path) -> Self {
self.work_tree = Some(path);
self
}
pub(crate) fn index_file(mut self, path: &'a Path) -> Self {
self.index_file = Some(path);
self
}
pub(crate) fn stdin(mut self, bytes: &'a [u8]) -> Self {
self.stdin = Some(bytes);
self
}
}
#[derive(Debug)]
pub(crate) struct GitPlumbing {
git_dir: PathBuf,
disabled_hooks: std::sync::Mutex<Option<tempfile::TempDir>>,
}
impl GitPlumbing {
pub(crate) fn new(git_dir: impl Into<PathBuf>) -> Self {
Self {
git_dir: git_dir.into(),
disabled_hooks: std::sync::Mutex::new(None),
}
}
pub(crate) fn git_dir(&self) -> &Path {
&self.git_dir
}
pub(crate) fn exists(&self) -> bool {
self.git_dir.join("HEAD").is_file()
}
pub(crate) fn init_bare(&self) -> Result<()> {
if self.exists() {
return Ok(());
}
if let Some(parent) = self.git_dir.parent() {
crate::file::create_dir_all(parent)?;
}
let mut cmd = self.base_command()?;
cmd.args(["init", "--bare", "--quiet"]).arg(&self.git_dir);
run_plumbing(cmd, None)?;
for (key, value) in [
("core.autocrlf", "false"),
("core.logAllRefUpdates", "false"),
("gc.auto", "0"),
#[cfg(unix)]
("core.symlinks", "true"),
] {
self.run(PlumbingCall::new(["config", key, value]))?;
}
Ok(())
}
pub(crate) fn run(&self, call: PlumbingCall<'_>) -> Result<()> {
self.output(call).map(|_| ())
}
pub(crate) fn output(&self, call: PlumbingCall<'_>) -> Result<Vec<u8>> {
let cmd = self.command(&call)?;
run_plumbing(cmd, call.stdin)
}
pub(crate) fn blob_starts_with(&self, oid: &str, prefix: &[u8]) -> Result<bool> {
use std::io::Read;
use std::process::Stdio;
let mut child = self
.command(&PlumbingCall::new(["cat-file", "blob", oid]))?
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()?;
let mut bytes = Vec::new();
let mut output = child
.stdout
.take()
.expect("stdout was piped")
.take(prefix.len() as u64);
let read = output.read_to_end(&mut bytes);
let complete = bytes.len() == prefix.len();
if complete || read.is_err() {
let _ = child.kill();
}
let status = child.wait()?;
read?;
if !complete && !status.success() {
eyre::bail!("failed to inspect Git blob {oid}");
}
Ok(bytes == prefix)
}
pub(crate) fn output_unchecked(&self, call: PlumbingCall<'_>) -> Result<std::process::Output> {
let cmd = self.command(&call)?;
spawn_plumbing(cmd, call.stdin)
}
pub(crate) fn status_inherited(
&self,
call: PlumbingCall<'_>,
) -> Result<std::process::ExitStatus> {
let mut cmd = self.command(&call)?;
cmd.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.stdin(std::process::Stdio::null());
cmd.status()
.wrap_err_with(|| format!("failed to run {}", describe_plumbing(&cmd)))
}
pub(crate) fn output_str(&self, call: PlumbingCall<'_>) -> Result<String> {
let out = self.output(call)?;
Ok(String::from_utf8_lossy(&out).trim().to_string())
}
pub(crate) fn network_output(&self, call: PlumbingCall<'_>) -> Result<std::process::Output> {
eyre::ensure!(
call.work_tree.is_none() && call.index_file.is_none() && call.stdin.is_none(),
"network Git calls do not accept a work tree, alternate index, or stdin"
);
let git =
plumbing_binary().ok_or_else(|| eyre!("no unattended git executable is available"))?;
let mut cmd = std::process::Command::new(git);
sanitize_git_command(&mut cmd);
if !console::user_attended_stderr() {
cmd.env("GIT_TERMINAL_PROMPT", "0");
}
cmd.args([
"-c",
&github_credential_config("github.com"),
"-c",
&github_credential_config("github.com:443"),
]);
cmd.env("GIT_OPTIONAL_LOCKS", "0")
.env("LC_ALL", "C")
.stdin(std::process::Stdio::null());
let mut git_dir = OsString::from("--git-dir=");
git_dir.push(&self.git_dir);
cmd.arg(git_dir);
let hooks = tempfile::tempdir()?;
let mut hooks_config = OsString::from("core.hooksPath=");
hooks_config.push(hooks.path());
cmd.arg("-c")
.arg(hooks_config)
.args(["-c", "advice.fetchShowForcedUpdates=false"]);
cmd.args(&call.args);
if let Some(cwd) = call.cwd {
cmd.current_dir(cwd);
}
spawn_plumbing(cmd, None)
}
fn base_command(&self) -> Result<std::process::Command> {
let git =
plumbing_binary().ok_or_else(|| eyre!("no unattended git executable is available"))?;
let mut cmd = std::process::Command::new(git);
sanitize_git_command(&mut cmd);
cmd.env_remove("GIT_CONFIG_COUNT")
.env_remove("GIT_CONFIG_PARAMETERS");
let mut hooks = self
.disabled_hooks
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if hooks.is_none() {
*hooks = Some(tempfile::tempdir()?);
}
let mut hooks_config = OsString::from("core.hooksPath=");
hooks_config.push(hooks.as_ref().expect("hooks directory initialized").path());
cmd.arg("-c").arg(hooks_config);
let null = if cfg!(windows) { "NUL" } else { "/dev/null" };
cmd.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", null)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_AUTHOR_NAME", "mise")
.env("GIT_AUTHOR_EMAIL", "mise@localhost")
.env("GIT_COMMITTER_NAME", "mise")
.env("GIT_COMMITTER_EMAIL", "mise@localhost")
.env("LC_ALL", "C")
.stdin(std::process::Stdio::null());
Ok(cmd)
}
fn command(&self, call: &PlumbingCall<'_>) -> Result<std::process::Command> {
let mut cmd = self.base_command()?;
let mut git_dir = OsString::from("--git-dir=");
git_dir.push(&self.git_dir);
cmd.arg(git_dir);
if let Some(work_tree) = call.work_tree {
let mut arg = OsString::from("--work-tree=");
arg.push(work_tree);
cmd.arg(arg);
}
cmd.args([
"-c",
"core.autocrlf=false",
"-c",
"core.quotePath=false",
"-c",
"advice.addEmbeddedRepo=false",
]);
cmd.args(&call.args);
if let Some(index) = call.index_file {
cmd.env("GIT_INDEX_FILE", index);
}
if let Some(cwd) = call.cwd {
cmd.current_dir(cwd);
}
if call.stdin.is_some() {
cmd.stdin(std::process::Stdio::piped());
}
Ok(cmd)
}
}
fn describe_plumbing(cmd: &std::process::Command) -> String {
let args = cmd
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
format!("git {}", args.join(" "))
}
fn spawn_plumbing(
mut cmd: std::process::Command,
stdin: Option<&[u8]>,
) -> Result<std::process::Output> {
use std::io::Write;
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd
.spawn()
.wrap_err_with(|| format!("failed to spawn {}", describe_plumbing(&cmd)))?;
if let Some(bytes) = stdin {
let mut pipe = child.stdin.take().expect("stdin was piped");
let _ = pipe.write_all(bytes);
drop(pipe);
}
child
.wait_with_output()
.wrap_err_with(|| format!("failed to run {}", describe_plumbing(&cmd)))
}
fn run_plumbing(cmd: std::process::Command, stdin: Option<&[u8]>) -> Result<Vec<u8>> {
let describe = describe_plumbing(&cmd);
let output = spawn_plumbing(cmd, stdin)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(eyre!(
"{describe} failed ({}): {}",
output.status,
stderr.trim()
));
}
Ok(output.stdout)
}
impl Debug for Git {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Git").field("dir", &self.dir).finish()
}
}
#[derive(Default)]
pub(crate) struct CloneOptions<'a> {
pr: Option<&'a dyn SingleReport>,
branch: Option<String>,
revision: Option<String>,
}
impl<'a> CloneOptions<'a> {
pub(crate) fn pr(mut self, pr: &'a dyn SingleReport) -> Self {
self.pr = Some(pr);
self
}
pub(crate) fn branch(mut self, branch: &str) -> Self {
self.branch = Some(branch.to_string());
self.revision = None;
self
}
pub(crate) fn revision(mut self, revision: &str) -> Self {
self.branch = None;
self.revision = Some(revision.to_string());
self
}
}
#[cfg(test)]
mod tests {
use super::{CloneOptions, Git, looks_like_sha, sanitize_git_cmd_runner, sanitize_git_env};
use crate::cmd::CmdLineRunner;
use crate::config::Settings;
use std::process::Command;
#[test]
fn network_calls_reject_unsupported_plumbing_fields() {
use super::{GitPlumbing, PlumbingCall};
let temp = tempfile::tempdir().unwrap();
let repo = GitPlumbing::new(temp.path().join("unused.git"));
for call in [
PlumbingCall::new(["fetch"]).work_tree(temp.path()),
PlumbingCall::new(["fetch"]).index_file(temp.path()),
PlumbingCall::new(["fetch"]).stdin(b"unexpected"),
] {
assert!(
repo.network_output(call)
.unwrap_err()
.to_string()
.contains("network Git calls do not accept")
);
}
}
#[test]
fn sha_detection() {
assert!(looks_like_sha("0123456789abcdef0123456789abcdef01234567"));
assert!(looks_like_sha(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
));
assert!(!looks_like_sha("main"));
assert!(!looks_like_sha("v1.2.3"));
assert!(!looks_like_sha("abcdef1")); assert!(!looks_like_sha(""));
assert!(!looks_like_sha("g123456789abcdef0123456789abcdef01234567")); }
#[test]
fn remote_ref_parser_prefers_branches_over_tags() {
let output = "\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/heads/release
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\trefs/tags/release
";
assert_eq!(
super::remote_ref_kind(output, "refs/heads/release", "refs/tags/release"),
Some(super::RemoteRefKind::Branch)
);
assert_eq!(
super::remote_ref_kind(output, "refs/heads/missing", "refs/tags/release"),
Some(super::RemoteRefKind::Tag)
);
assert_eq!(
super::remote_ref_kind(output, "refs/heads/missing", "refs/tags/missing"),
None
);
}
#[test]
fn reads_files_from_the_merge_base_and_head() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let git = |args: &[&str]| {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_string()
};
git(&["-c", "init.defaultBranch=main", "init", "-q"]);
git(&["config", "user.email", "test@example.com"]);
git(&["config", "user.name", "Test"]);
std::fs::write(root.join("lockfile"), "before\n").unwrap();
git(&["add", "lockfile"]);
git(&["commit", "-q", "-m", "before"]);
let base = git(&["rev-parse", "HEAD"]);
std::fs::write(root.join("lockfile"), "after\n").unwrap();
git(&["commit", "-q", "-am", "after"]);
let head = git(&["rev-parse", "HEAD"]);
let repo = Git::new(root);
assert_eq!(repo.merge_base(&base, &head).unwrap(), base);
assert_eq!(
repo.file_at_revision(&base, std::path::Path::new("lockfile"))
.unwrap()
.as_deref(),
Some("before\n")
);
assert_eq!(
repo.file_at_revision(&head, std::path::Path::new("lockfile"))
.unwrap()
.as_deref(),
Some("after\n")
);
assert_eq!(
repo.file_at_revision(&head, std::path::Path::new("missing"))
.unwrap(),
None
);
}
#[test]
fn update_resolves_short_branches_and_tags() {
let tmp = tempfile::tempdir().unwrap();
let origin = tmp.path().join("origin");
std::fs::create_dir_all(&origin).unwrap();
let git_in = |dir: &std::path::Path, args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap().trim().to_string()
};
git_in(&origin, &["-c", "init.defaultBranch=main", "init", "-q"]);
git_in(&origin, &["config", "user.email", "test@example.com"]);
git_in(&origin, &["config", "user.name", "Test"]);
std::fs::write(origin.join("version"), "release\n").unwrap();
git_in(&origin, &["add", "version"]);
git_in(&origin, &["commit", "-q", "-m", "release"]);
let release_sha = git_in(&origin, &["rev-parse", "HEAD"]);
git_in(&origin, &["branch", "release-branch"]);
git_in(&origin, &["branch", "collision"]);
git_in(&origin, &["tag", "lightweight-v1"]);
git_in(
&origin,
&["tag", "-a", "annotated-v1", "-m", "annotated-v1"],
);
std::fs::write(origin.join("version"), "tag-collision\n").unwrap();
git_in(&origin, &["commit", "-q", "-am", "tag collision"]);
let tag_collision_sha = git_in(&origin, &["rev-parse", "HEAD"]);
git_in(&origin, &["tag", "-a", "collision", "-m", "collision"]);
std::fs::write(origin.join("version"), "main\n").unwrap();
git_in(&origin, &["commit", "-q", "-am", "main"]);
let cases = [
("release-branch", release_sha.as_str()),
("lightweight-v1", release_sha.as_str()),
("annotated-v1", release_sha.as_str()),
("refs/tags/annotated-v1", release_sha.as_str()),
(release_sha.as_str(), release_sha.as_str()),
("collision", release_sha.as_str()),
("refs/tags/collision", tag_collision_sha.as_str()),
];
let url = format!("file://{}", origin.display());
for (index, (selector, expected_sha)) in cases.into_iter().enumerate() {
let clone = tmp.path().join(format!("clone-{index}"));
git_in(tmp.path(), &["clone", "-q", &url, clone.to_str().unwrap()]);
if selector == "annotated-v1" {
git_in(&clone, &["branch", "annotated-v1"]);
}
Git::new(&clone)
.update(Some(selector.to_string()))
.unwrap_or_else(|err| panic!("update {selector} failed: {err:#}"));
assert_eq!(
git_in(&clone, &["rev-parse", "HEAD"]),
expected_sha,
"selector {selector} checked out the wrong commit"
);
}
let clone = tmp.path().join("clone-update-tag-full-ref");
git_in(tmp.path(), &["clone", "-q", &url, clone.to_str().unwrap()]);
Git::new(&clone)
.update_tag("refs/tags/annotated-v1".to_string())
.unwrap_or_else(|err| panic!("update_tag with full ref failed: {err:#}"));
assert_eq!(git_in(&clone, &["rev-parse", "HEAD"]), release_sha);
}
#[test]
fn worktree_main_checkout_equivalent() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().canonicalize().unwrap();
let main = base.join("main");
let wt = base.join("wt");
std::fs::create_dir_all(main.join(".git/worktrees/wt")).unwrap();
std::fs::create_dir_all(wt.join("sub")).unwrap();
std::fs::write(main.join(".git/worktrees/wt/commondir"), "../..\n").unwrap();
std::fs::write(
wt.join(".git"),
format!("gitdir: {}\n", main.join(".git/worktrees/wt").display()),
)
.unwrap();
assert_eq!(super::main_checkout_equivalent(&wt), Some(main.clone()));
assert_eq!(
super::main_checkout_equivalent(&wt.join("sub/mise.toml")),
Some(main.join("sub/mise.toml"))
);
assert_eq!(super::main_checkout_equivalent(&main), None);
assert_eq!(super::main_checkout_equivalent(&base), None);
let bare = base.join("bare.git");
let bare_wt = base.join("bare-wt");
std::fs::create_dir_all(bare.join("worktrees/bare-wt")).unwrap();
std::fs::create_dir_all(&bare_wt).unwrap();
std::fs::write(bare.join("worktrees/bare-wt/commondir"), "../..\n").unwrap();
std::fs::write(
bare_wt.join(".git"),
format!("gitdir: {}\n", bare.join("worktrees/bare-wt").display()),
)
.unwrap();
assert_eq!(super::main_checkout_equivalent(&bare_wt), None);
let subm = main.join("subm");
std::fs::create_dir_all(main.join(".git/modules/subm")).unwrap();
std::fs::create_dir_all(&subm).unwrap();
std::fs::write(
subm.join(".git"),
format!("gitdir: {}\n", main.join(".git/modules/subm").display()),
)
.unwrap();
assert_eq!(super::main_checkout_equivalent(&subm), None);
assert_eq!(
super::main_checkout_equivalent(&subm.join("mise.toml")),
None
);
let wt_subm = wt.join("subm");
std::fs::create_dir_all(&wt_subm).unwrap();
std::fs::write(
wt_subm.join(".git"),
format!("gitdir: {}\n", main.join(".git/modules/subm").display()),
)
.unwrap();
assert_eq!(
super::main_checkout_equivalent(&wt_subm.join("mise.toml")),
Some(subm.join("mise.toml"))
);
}
#[test]
fn git_commands_ignore_inherited_work_tree() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
let cache = tmp.path().join("cache");
let work_tree = tmp.path().join("work-tree");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&work_tree).unwrap();
let git_in = |dir: &std::path::Path, args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
out
};
git_in(&src, &["-c", "init.defaultBranch=main", "init", "-q"]);
std::fs::write(src.join("file.txt"), "hello\n").unwrap();
git_in(&src, &["add", "file.txt"]);
git_in(
&src,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"-m",
"main",
],
);
let url = format!("file://{}", src.display());
let clone = Command::new("git")
.args(["clone", "-q", &url])
.arg(&cache)
.output()
.expect("spawn git clone");
assert!(
clone.status.success(),
"git clone failed: {}",
String::from_utf8_lossy(&clone.stderr)
);
std::fs::remove_file(cache.join("file.txt")).unwrap();
let output = sanitize_git_env(
git_cmd!(&cache, "checkout", "--force", "HEAD")
.env("GIT_WORK_TREE", &work_tree)
.env("GIT_INDEX_FILE", work_tree.join("index")),
)
.stderr_to_stdout()
.stdout_capture()
.unchecked()
.run()
.expect("run git checkout");
assert!(
output.status.success(),
"git checkout failed: {}",
String::from_utf8_lossy(&output.stdout)
);
assert!(cache.join("file.txt").exists());
assert!(!work_tree.join("file.txt").exists());
assert!(!work_tree.join("index").exists());
let clone_cache = tmp.path().join("clone-cache");
sanitize_git_cmd_runner(
CmdLineRunner::new("git")
.arg("clone")
.arg("-q")
.arg(&url)
.arg(&clone_cache)
.env("GIT_WORK_TREE", &work_tree),
)
.execute()
.expect("git clone should ignore inherited GIT_WORK_TREE");
assert!(clone_cache.join("file.txt").exists());
assert!(!work_tree.join("file.txt").exists());
}
#[test]
fn clone_by_sha_does_not_panic() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let git_in = |dir: &std::path::Path, args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
out
};
git_in(&src, &["-c", "init.defaultBranch=main", "init", "-q"]);
git_in(
&src,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"--allow-empty",
"-m",
"main",
],
);
git_in(&src, &["checkout", "-q", "-b", "feature"]);
git_in(
&src,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"--allow-empty",
"-m",
"feature",
],
);
let sha = String::from_utf8(git_in(&src, &["rev-parse", "HEAD"]).stdout)
.unwrap()
.trim()
.to_string();
assert_eq!(sha.len(), 40);
git_in(&src, &["checkout", "-q", "main"]);
let url = format!("file://{}", src.display());
let backups = (Settings::get().gix, Settings::get().libgit2);
Settings::override_with(|s| {
s.gix = Some(true);
s.libgit2 = Some(false);
});
let dst_gix = tmp.path().join("dst-gix");
Git::new(&dst_gix)
.clone(&url, CloneOptions::default().revision(&sha))
.expect("gix clone with SHA must not panic and must succeed");
let head = git_in(&dst_gix, &["rev-parse", "HEAD"]);
assert_eq!(String::from_utf8(head.stdout).unwrap().trim(), sha);
let short_sha = &sha[..12];
let dst_short = tmp.path().join("dst-short");
Git::new(&dst_short)
.clone(&url, CloneOptions::default().revision(short_sha))
.expect("clone with abbreviated revision must succeed");
let head = git_in(&dst_short, &["rev-parse", "HEAD"]);
assert_eq!(String::from_utf8(head.stdout).unwrap().trim(), sha);
let dst_invalid = tmp.path().join("dst-invalid");
let err = Git::new(&dst_invalid)
.clone(&url, CloneOptions::default().revision("deadbeef"))
.expect_err("unknown revision must fail");
assert!(format!("{err:#}").contains("deadbeef"));
Settings::override_with(|s| {
s.gix = Some(false);
s.libgit2 = Some(false);
});
let dst_cli = tmp.path().join("dst-cli");
Git::new(&dst_cli)
.clone(&url, CloneOptions::default().branch(&sha))
.expect("CLI clone with SHA must succeed");
let head = git_in(&dst_cli, &["rev-parse", "HEAD"]);
assert_eq!(String::from_utf8(head.stdout).unwrap().trim(), sha);
Settings::override_with(|s| {
s.gix = Some(backups.0);
s.libgit2 = Some(backups.1);
});
}
}
#[cfg(test)]
mod plumbing_tests {
use super::*;
#[test]
fn plumbing_isolates_user_config_and_keeps_the_scratch_index() {
let tmp = tempfile::tempdir().unwrap();
let repo = GitPlumbing::new(tmp.path().join("shadow.git"));
let index = tmp.path().join("scratch-index");
let work_tree = tmp.path().join("tree");
let cmd = repo
.command(
&PlumbingCall::new(["write-tree"])
.work_tree(&work_tree)
.index_file(&index),
)
.unwrap();
let envs: HashMap<_, _> = cmd
.get_envs()
.map(|(k, v)| (k.to_os_string(), v.map(|v| v.to_os_string())))
.collect();
assert_eq!(
envs.get(OsStr::new("GIT_INDEX_FILE")).cloned().flatten(),
Some(index.into_os_string())
);
assert_eq!(
envs.get(OsStr::new("GIT_DIR")).cloned(),
Some(None),
"GIT_DIR must be removed rather than inherited"
);
assert_eq!(
envs.get(OsStr::new("GIT_CONFIG_NOSYSTEM"))
.cloned()
.flatten(),
Some(OsString::from("1"))
);
assert!(envs.contains_key(OsStr::new("GIT_CONFIG_GLOBAL")));
for variable in ["GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS"] {
assert_eq!(envs.get(OsStr::new(variable)), Some(&None));
}
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
args.iter().any(|arg| arg.starts_with("--git-dir=")),
"{args:?}"
);
assert!(
args.iter().any(|arg| arg.starts_with("--work-tree=")),
"{args:?}"
);
assert_eq!(args.last().map(String::as_str), Some("write-tree"));
}
#[test]
fn init_bare_is_idempotent_and_ignores_global_config() {
if plumbing_binary().is_none() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let repo = GitPlumbing::new(tmp.path().join("shadow.git"));
assert!(!repo.exists());
repo.init_bare().unwrap();
assert!(repo.exists());
repo.init_bare().unwrap();
let hooks = repo
.output_str(PlumbingCall::new(["config", "--get", "gc.auto"]))
.unwrap();
assert_eq!(hooks, "0");
let global = repo
.output_unchecked(PlumbingCall::new([
"config",
"--global",
"--get",
"core.hooksPath",
]))
.unwrap();
assert!(!global.status.success() || global.stdout.is_empty());
}
#[test]
fn network_commands_override_hooks_with_a_private_directory() {
if plumbing_binary().is_none() {
return;
}
let temp = tempfile::tempdir().unwrap();
let repo = GitPlumbing::new(temp.path().join("network.git"));
repo.init_bare().unwrap();
repo.run(PlumbingCall::new([
"config",
"core.hooksPath",
"untrusted-hooks",
]))
.unwrap();
let internal = repo
.output_str(PlumbingCall::new(["config", "--get", "core.hooksPath"]))
.unwrap();
assert!(Path::new(&internal).is_dir());
assert_eq!(std::fs::read_dir(&internal).unwrap().count(), 0);
let out = repo
.network_output(PlumbingCall::new(["config", "--get", "core.hooksPath"]))
.unwrap();
assert!(out.status.success());
let hooks = String::from_utf8(out.stdout).unwrap();
let hooks = Path::new(hooks.trim());
assert!(hooks.is_absolute());
assert_ne!(hooks, Path::new("/dev/null"));
assert!(
!hooks.exists(),
"temporary hooks directory was not cleaned up"
);
}
}