1
0
mirror of https://github.com/j178/prek.git synced 2026-04-30 10:20:26 +02:00
Files
prek/src/git.rs
T
2025-03-11 13:35:37 +08:00

413 lines
11 KiB
Rust

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::LazyLock;
use anyhow::Result;
use itertools::Itertools;
use tokio::io::AsyncWriteExt;
use tracing::warn;
use crate::process;
use crate::process::Cmd;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Command(#[from] process::Error),
#[error("Failed to find git: {0}")]
GitNotFound(#[from] which::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
}
pub static GIT: LazyLock<Result<PathBuf, which::Error>> = LazyLock::new(|| which::which("git"));
static GIT_ENV_REMOVE: LazyLock<()> = LazyLock::new(|| {
let keep = &[
"GIT_EXEC_PATH",
"GIT_SSH",
"GIT_SSH_COMMAND",
"GIT_SSL_CAINFO",
"GIT_SSL_NO_VERIFY",
"GIT_CONFIG_COUNT",
"GIT_HTTP_PROXY_AUTHMETHOD",
"GIT_ALLOW_PROTOCOL",
"GIT_ASKPASS",
];
let to_remove = std::env::vars().filter(|(k, _)| {
k.starts_with("GIT_")
&& !k.starts_with("GIT_CONFIG_KEY_")
&& !k.starts_with("GIT_CONFIG_VALUE_")
&& !keep.contains(&k.as_str())
});
for (k, _) in to_remove {
unsafe { std::env::remove_var(&k) };
}
});
pub fn git_cmd(summary: &str) -> Result<Cmd, Error> {
let mut cmd = Cmd::new(GIT.as_ref().map_err(|&e| Error::GitNotFound(e))?, summary);
cmd.arg("-c").arg("core.useBuiltinFSMonitor=false");
LazyLock::force(&GIT_ENV_REMOVE);
Ok(cmd)
}
fn zsplit(s: &[u8]) -> Vec<String> {
s.split(|&b| b == b'\0')
.filter_map(|slice| {
if slice.is_empty() {
None
} else {
Some(String::from_utf8_lossy(slice).to_string())
}
})
.collect()
}
pub async fn intent_to_add_files() -> Result<Vec<String>, Error> {
let output = git_cmd("get intent to add files")?
.arg("diff")
.arg("--no-ext-diff")
.arg("--ignore-submodules")
.arg("--diff-filter=A")
.arg("--name-only")
.arg("-z")
.check(true)
.output()
.await?;
Ok(zsplit(&output.stdout))
}
pub async fn get_changed_files(old: &str, new: &str) -> Result<Vec<String>, Error> {
let output = git_cmd("get changed files")?
.arg("diff")
.arg("--name-only")
.arg("--diff-filter=ACMRT")
.arg("--no-ext-diff") // Disable external diff drivers
.arg("-z") // Use NUL as line terminator
.arg(format!("{old}...{new}"))
.check(true)
.output()
.await?;
Ok(zsplit(&output.stdout))
}
pub async fn get_all_files() -> Result<Vec<String>, Error> {
let output = git_cmd("get git all files")?
.arg("ls-files")
.arg("-z")
.check(true)
.output()
.await?;
Ok(zsplit(&output.stdout))
}
pub async fn get_git_dir() -> Result<PathBuf, Error> {
let output = git_cmd("get git dir")?
.arg("rev-parse")
.arg("--git-dir")
.check(true)
.output()
.await?;
Ok(PathBuf::from(
String::from_utf8_lossy(&output.stdout).trim(),
))
}
pub async fn get_git_common_dir() -> Result<PathBuf, Error> {
let output = git_cmd("get git common dir")?
.arg("rev-parse")
.arg("--git-common-dir")
.check(true)
.output()
.await?;
if output.stdout.trim_ascii().is_empty() {
Ok(get_git_dir().await?)
} else {
Ok(PathBuf::from(
String::from_utf8_lossy(&output.stdout).trim(),
))
}
}
pub async fn get_staged_files() -> Result<Vec<String>, Error> {
let output = git_cmd("get staged files")?
.arg("diff")
.arg("--staged")
.arg("--name-only")
.arg("--diff-filter=ACMRTUXB") // Everything except for D
.arg("--no-ext-diff") // Disable external diff drivers
.arg("-z") // Use NUL as line terminator
.check(true)
.output()
.await?;
Ok(zsplit(&output.stdout))
}
pub async fn has_unmerged_paths() -> Result<bool, Error> {
let output = git_cmd("check has unmerged paths")?
.arg("ls-files")
.arg("--unmerged")
.check(true)
.output()
.await?;
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
}
pub async fn is_in_merge_conflict() -> Result<bool, Error> {
let git_dir = get_git_dir().await?;
Ok(git_dir.join("MERGE_HEAD").try_exists()? && git_dir.join("MERGE_MSG").try_exists()?)
}
pub async fn get_conflicted_files() -> Result<Vec<String>, Error> {
let tree = git_cmd("git write-tree")?
.arg("write-tree")
.check(true)
.output()
.await?;
let output = git_cmd("get conflicted files")?
.arg("diff")
.arg("--name-only")
.arg("--no-ext-diff") // Disable external diff drivers
.arg("-z") // Use NUL as line terminator
.arg("-m")
.arg(String::from_utf8_lossy(&tree.stdout).trim())
.arg("HEAD")
.arg("MERGE_HEAD")
.check(true)
.output()
.await?;
Ok(zsplit(&output.stdout)
.into_iter()
.chain(parse_merge_msg_for_conflicts().await?)
.collect::<HashSet<String>>()
.into_iter()
.collect())
}
async fn parse_merge_msg_for_conflicts() -> Result<Vec<String>, Error> {
let git_dir = get_git_dir().await?;
let merge_msg = git_dir.join("MERGE_MSG");
let content = fs_err::read_to_string(&merge_msg)?;
let conflicts = content
.lines()
// Conflicted files start with tabs
.filter(|line| line.starts_with('\t') || line.starts_with("#\t"))
.map(|line| line.trim_start_matches('#').trim().to_string())
.collect();
Ok(conflicts)
}
pub async fn get_diff() -> Result<Vec<u8>, Error> {
let output = git_cmd("git diff")?
.arg("diff")
.arg("--no-ext-diff") // Disable external diff drivers
.arg("--no-textconv")
.arg("--ignore-submodules")
.check(true)
.output()
.await?;
Ok(output.stdout)
}
/// Create a tree object from the current index.
///
/// The name of the new tree object is printed to standard output.
/// The index must be in a fully merged state.
pub async fn write_tree() -> Result<String, Error> {
let output = git_cmd("git write-tree")?
.arg("write-tree")
.check(true)
.output()
.await?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Get the path of the top-level directory of the working tree.
pub async fn get_root() -> Result<PathBuf, Error> {
let output = git_cmd("get git root")?
.arg("rev-parse")
.arg("--show-toplevel")
.check(true)
.output()
.await?;
Ok(PathBuf::from(
String::from_utf8_lossy(&output.stdout).trim(),
))
}
pub async fn is_dirty(path: &Path) -> Result<bool, Error> {
let mut cmd = git_cmd("check git is dirty")?;
let output = cmd
.arg("diff")
.arg("--quiet") // Implies `--exit-code`
.arg("--no-ext-diff") // Disable external diff drivers
.arg(path)
.check(false)
.output()
.await?;
if output.status.success() {
Ok(false)
} else if output.status.code() == Some(1) {
Ok(true)
} else {
Err(cmd.check_status(output.status).unwrap_err().into())
}
}
async fn init_repo(url: &str, path: &Path) -> Result<(), Error> {
git_cmd("init git repo")?
.arg("init")
.arg("--template=")
.arg(path)
.check(true)
.output()
.await?;
git_cmd("add git remote")?
.current_dir(path)
.arg("remote")
.arg("add")
.arg("origin")
.arg(url)
.check(true)
.output()
.await?;
Ok(())
}
async fn shallow_clone(rev: &str, path: &Path) -> Result<(), Error> {
git_cmd("git shallow clone")?
.current_dir(path)
.arg("-c")
.arg("protocol.version=2")
.arg("fetch")
.arg("origin")
.arg(rev)
.arg("--depth=1")
.check(true)
.output()
.await?;
git_cmd("git checkout")?
.current_dir(path)
.arg("checkout")
.arg("FETCH_HEAD")
.check(true)
.output()
.await?;
git_cmd("update git submodules")?
.current_dir(path)
.arg("-c")
.arg("protocol.version=2")
.arg("submodule")
.arg("update")
.arg("--init")
.arg("--recursive")
.arg("--depth=1")
.check(true)
.output()
.await?;
Ok(())
}
async fn full_clone(rev: &str, path: &Path) -> Result<(), Error> {
git_cmd("git full clone")?
.current_dir(path)
.arg("fetch")
.arg("origin")
.arg("--tags")
.check(true)
.output()
.await?;
git_cmd("git checkout")?
.current_dir(path)
.arg("checkout")
.arg(rev)
.check(true)
.output()
.await?;
git_cmd("update git submodules")?
.current_dir(path)
.arg("submodule")
.arg("update")
.arg("--init")
.arg("--recursive")
.check(true)
.output()
.await?;
Ok(())
}
pub async fn clone_repo(url: &str, rev: &str, path: &Path) -> Result<(), Error> {
init_repo(url, path).await?;
if let Err(err) = shallow_clone(rev, path).await {
warn!(?err, "Failed to shallow clone, falling back to full clone");
full_clone(rev, path).await
} else {
Ok(())
}
}
pub async fn has_hooks_path_set() -> Result<bool> {
let output = git_cmd("get git hooks path")?
.arg("config")
.arg("--get")
.arg("core.hooksPath")
.check(false)
.output()
.await?;
if output.status.success() {
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
} else {
Ok(false)
}
}
pub async fn lfs_files<T: FromIterator<String>>(paths: &[&String]) -> Result<T, Error> {
let mut job = git_cmd("git check-attr")?
.arg("check-attr")
.arg("filter")
.arg("-z")
.arg("--stdin")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.check(true)
// .output()
.spawn()?;
{
let mut stdin = job.stdin.take().expect("Failed to open stdin");
stdin.write_all(paths.iter().join("\0").as_ref()).await?;
}
Ok(
String::from_utf8_lossy(&job.wait_with_output().await?.stdout)
.trim()
.split('\0')
.tuples::<(_, _, _)>()
.filter_map(|(file, _, attr)| {
if attr == "lfs" {
Some(file.to_owned())
} else {
None
}
})
.collect(),
)
}