use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex, OnceLock};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::{Child, Command};
use tokio::task::JoinHandle;
use crate::git::blobs::BlobReader;
use crate::git::credentials::Credentials;
use crate::git::files::{ChangedFile, parse_raw};
use crate::git::{GitError, Oid, Version, git, github_repo_from_url, piped, stdout};
const NO_LAZY_FETCH_VERSION: Version = Version(2, 44, 0);
const CHECK_ATTR_SOURCE_VERSION: Version = Version(2, 40, 0);
pub type Progress<'a> = &'a (dyn Fn(String) + Send + Sync);
#[derive(Debug, Clone)]
pub struct Repo {
pub path: PathBuf,
pub remote: String,
pub partial: bool,
version: Version,
credentials: Credentials,
}
pub const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrRefs {
pub head: Oid,
pub base: Oid,
pub merge_base: Oid,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
pub oid: Oid,
pub subject: String,
}
fn repo_lock(path: &Path) -> Arc<tokio::sync::Mutex<()>> {
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>> = OnceLock::new();
let mut locks = LOCKS
.get_or_init(Mutex::default)
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
locks.entry(path.to_owned()).or_default().clone()
}
impl Repo {
pub async fn find_local(dir: &Path, owner: &str, name: &str) -> Result<Option<Repo>, GitError> {
let Ok(top) = crate::git::run(Some(dir), &["rev-parse", "--show-toplevel"]).await else {
return Ok(None);
};
let top = PathBuf::from(top.trim());
let remotes = crate::git::remotes(&top).await?;
let Some(remote) = remotes.iter().find(|r| {
github_repo_from_url(&r.url).is_some_and(|repo| {
repo.owner.eq_ignore_ascii_case(owner) && repo.name.eq_ignore_ascii_case(name)
})
}) else {
return Ok(None);
};
Self::open_local(&top, &remote.name).await.map(Some)
}
pub async fn open_local(top: &Path, remote: &str) -> Result<Repo, GitError> {
let version = crate::git::version().await?;
Self::open(top.to_owned(), remote, version, Credentials::Ambient).await
}
pub async fn open_cache(
cache_root: &Path,
owner: &str,
name: &str,
url: &str,
credentials: Credentials,
progress: Progress<'_>,
) -> Result<Repo, GitError> {
let path = cache_root
.join("repos")
.join(owner.to_ascii_lowercase())
.join(format!("{}.git", name.to_ascii_lowercase()));
let _guard = repo_lock(&path).lock_owned().await;
let version = crate::git::version().await?;
if !path.join("HEAD").exists() {
let parent = path.parent().ok_or_else(|| {
std::io::Error::other(format!("no parent for {}", path.display()))
})?;
std::fs::create_dir_all(parent)?;
let tmp = parent.join(format!(".{name}.git.tmp-{}", std::process::id()));
if tmp.exists() {
std::fs::remove_dir_all(&tmp)?;
}
progress(format!("Cloning {owner}/{name}"));
let mut cmd = git(None);
credentials.apply(&mut cmd);
cmd.args(["clone", "--bare", "--filter=blob:none", "--progress", url])
.arg(&tmp);
if let Err(err) = run_with_progress(cmd, "clone", None, progress).await {
let _ = std::fs::remove_dir_all(&tmp);
return Err(err);
}
std::fs::rename(&tmp, &path)?;
}
Self::open(path, "origin", version, credentials).await
}
async fn open(
path: PathBuf,
remote: &str,
version: Version,
credentials: Credentials,
) -> Result<Repo, GitError> {
let mut repo = Repo {
path,
remote: remote.to_owned(),
partial: false,
version,
credentials,
};
repo.partial = repo.is_promisor().await;
Ok(repo)
}
fn cmd(&self) -> Command {
let mut cmd = git(Some(&self.path));
self.credentials.apply(&mut cmd);
cmd
}
async fn run(&self, args: &[&str]) -> Result<String, GitError> {
stdout(&self.cmd().args(args).output().await?, &args.join(" "))
}
async fn is_promisor(&self) -> bool {
let key = format!("remote.{}.promisor", self.remote);
self.run(&["config", "--get", &key])
.await
.is_ok_and(|v| v.trim() == "true")
}
fn ref_name(number: u64, what: &str) -> String {
format!("refs/ghtui/pr/{number}/{what}")
}
pub async fn fetch_pr(
&self,
number: u64,
base_branch: &str,
base_oid: Option<&Oid>,
progress: Progress<'_>,
) -> Result<PrRefs, GitError> {
let head_ref = Self::ref_name(number, "head");
let base_ref = Self::ref_name(number, "base");
progress(format!("Fetching #{number}"));
let head_spec = format!("+refs/pull/{number}/head:{head_ref}");
let base_spec = format!("+refs/heads/{base_branch}:{base_ref}");
self.fetch(&[&head_spec, &base_spec], progress).await?;
let head = self.rev_parse(&head_ref).await?;
let base = match base_oid {
Some(oid) => self.ensure(oid, progress).await.map(|()| oid.clone())?,
None => self.rev_parse(&base_ref).await?,
};
let merge_base = self.merge_base(&base, &head).await?;
Ok(PrRefs {
head,
base,
merge_base,
})
}
pub async fn merge_base(&self, a: &str, b: &str) -> Result<Oid, GitError> {
Oid::parse(self.run(&["merge-base", a, b]).await?.trim())
}
pub async fn commits(&self, from: &str, to: &str) -> Result<Vec<Commit>, GitError> {
let out = self
.run(&[
"log",
"--reverse",
"--format=%H%x1f%s",
&format!("{from}..{to}"),
])
.await?;
Ok(out
.lines()
.filter_map(|l| l.split_once('\u{1f}'))
.filter_map(|(sha, subject)| {
Some(Commit {
oid: Oid::parse(sha).ok()?,
subject: subject.to_owned(),
})
})
.collect())
}
pub async fn rev_parse(&self, rev: &str) -> Result<Oid, GitError> {
let out = self
.run(&[
"rev-parse",
"--verify",
"--quiet",
&format!("{rev}^{{commit}}"),
])
.await?;
Oid::parse(out.trim())
}
pub async fn pin_seen(&self, number: u64, sha: &str) -> Result<(), GitError> {
let name = Self::ref_name(number, &format!("seen/{sha}"));
self.run(&["update-ref", &name, sha]).await.map(drop)
}
pub async fn has(&self, rev: &str) -> bool {
self.cmd()
.env("GIT_NO_LAZY_FETCH", "1")
.args(["cat-file", "-e", rev])
.status()
.await
.is_ok_and(|s| s.success())
}
pub async fn commit_refs(&self, oid: &Oid, progress: Progress<'_>) -> Result<PrRefs, GitError> {
self.ensure(oid, progress).await?;
let parent = match self.rev_parse(&format!("{oid}^")).await {
Ok(parent) => parent,
Err(_) => Oid::parse(EMPTY_TREE)?,
};
Ok(PrRefs {
head: oid.clone(),
base: parent.clone(),
merge_base: parent,
})
}
pub async fn range_refs(
&self,
from: &Oid,
to: &Oid,
progress: Progress<'_>,
) -> Result<PrRefs, GitError> {
self.ensure(from, progress).await?;
self.ensure(to, progress).await?;
Ok(PrRefs {
head: to.clone(),
base: from.clone(),
merge_base: from.clone(),
})
}
pub async fn fetch_head(&self, into: &str, progress: Progress<'_>) -> Result<(), GitError> {
self.fetch(&[&format!("+HEAD:{into}")], progress).await
}
async fn fetch(&self, refspecs: &[&str], progress: Progress<'_>) -> Result<(), GitError> {
let _guard = repo_lock(&self.path).lock_owned().await;
let mut cmd = self.cmd();
cmd.args([
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--progress",
&self.remote,
])
.args(refspecs);
run_with_progress(cmd, "fetch", None, progress)
.await
.map(drop)
}
pub async fn file_names(&self, rev: &str) -> Result<Vec<String>, GitError> {
let out = self
.run(&["ls-tree", "-r", "-z", "--name-only", rev])
.await?;
Ok(out
.split('\0')
.filter(|p| !p.is_empty())
.map(str::to_owned)
.collect())
}
pub async fn file_text(&self, rev: &str, path: &str) -> Result<String, GitError> {
self.run(&["cat-file", "blob", &format!("{rev}:{path}")])
.await
}
pub async fn ensure(&self, oid: &Oid, progress: Progress<'_>) -> Result<(), GitError> {
if self.has(oid).await {
return Ok(());
}
progress(format!("Fetching {}", oid.get(..7).unwrap_or(oid)));
self.fetch(&[oid], progress).await
}
pub async fn changed_files(&self, from: &str, to: &str) -> Result<Vec<ChangedFile>, GitError> {
let out = self
.run(&["diff", "--raw", "-z", "--no-abbrev", "-M", from, to])
.await?;
parse_raw(&out)
}
pub async fn missing(&self, oids: &[String]) -> Result<Vec<String>, GitError> {
if oids.is_empty() {
return Ok(Vec::new());
}
if self.version < NO_LAZY_FETCH_VERSION {
return Ok(oids.to_vec());
}
let mut cmd = self.cmd();
cmd.env("GIT_NO_LAZY_FETCH", "1")
.args(["cat-file", "--batch-check=%(objectname)"]);
let out = run_with_stdin(cmd, "cat-file --batch-check", oids.join("\n") + "\n").await?;
Ok(out
.lines()
.filter_map(|l| l.strip_suffix(" missing"))
.map(str::to_owned)
.collect())
}
pub async fn prefetch(&self, oids: &[String]) -> Result<usize, GitError> {
if !self.partial {
return Ok(0);
}
let mut unique: Vec<String> = oids.to_vec();
unique.sort();
unique.dedup();
let missing = self.missing(&unique).await?;
if missing.is_empty() {
return Ok(0);
}
let _guard = repo_lock(&self.path).lock_owned().await;
let mut cmd = self.cmd();
cmd.args([
"-c",
"fetch.negotiationAlgorithm=noop",
"fetch",
"--no-tags",
"--no-write-fetch-head",
"--recurse-submodules=no",
"--filter=blob:none",
"--progress",
"--stdin",
&self.remote,
]);
let input = Some(missing.join("\n") + "\n");
run_with_progress(cmd, "fetch --stdin", input, &|_| {}).await?;
Ok(missing.len())
}
pub async fn generated_paths(
&self,
commit: &str,
paths: &[&str],
) -> Result<HashSet<String>, GitError> {
if paths.is_empty() || self.version < CHECK_ATTR_SOURCE_VERSION {
return Ok(HashSet::new());
}
let mut cmd = self.cmd();
cmd.args([
"check-attr",
"-z",
"--stdin",
&format!("--source={commit}"),
"linguist-generated",
"linguist-vendored",
]);
let mut input = paths.join("\0");
input.push('\0');
let out = run_with_stdin(cmd, "check-attr", input).await?;
let fields: Vec<&str> = out.split('\0').collect();
let (triples, _) = fields.as_chunks::<3>();
Ok(triples
.iter()
.filter(|[_, _, value]| matches!(*value, "set" | "true"))
.map(|[path, _, _]| (*path).to_owned())
.collect())
}
pub fn blob_reader(&self) -> Result<BlobReader, GitError> {
let repo = self.clone();
BlobReader::spawn(move || repo.cmd())
}
}
async fn run_with_stdin(mut cmd: Command, what: &str, input: String) -> Result<String, GitError> {
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let writer = feed(&mut child, input)?;
let output = child.wait_with_output().await?;
let _ = writer.await;
stdout(&output, what)
}
fn feed(child: &mut Child, input: String) -> Result<JoinHandle<()>, GitError> {
let mut stdin = piped(child.stdin.take(), "stdin")?;
Ok(tokio::spawn(async move {
let _ = stdin.write_all(input.as_bytes()).await;
}))
}
async fn run_with_progress(
mut cmd: Command,
what: &str,
input: Option<String>,
progress: Progress<'_>,
) -> Result<String, GitError> {
if input.is_some() {
cmd.stdin(Stdio::piped());
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let writer = input.map(|input| feed(&mut child, input)).transpose()?;
let mut stderr = piped(child.stderr.take(), "stderr")?;
let mut stdout = piped(child.stdout.take(), "stdout")?;
let out_task = tokio::spawn(async move {
let mut buf = Vec::new();
let _ = stdout.read_to_end(&mut buf).await;
buf
});
let mut all = Vec::new();
let mut chunk = [0u8; 4096];
let mut line = Vec::new();
loop {
let n = stderr.read(&mut chunk).await?;
let Some(read) = chunk.get(..n).filter(|read| !read.is_empty()) else {
break;
};
all.extend_from_slice(read);
for &b in read {
if b == b'\r' || b == b'\n' {
let text = String::from_utf8_lossy(&line).trim().to_owned();
if !text.is_empty() {
progress(text);
}
line.clear();
} else {
line.push(b);
}
}
}
if let Some(writer) = writer {
let _ = writer.await;
}
let status = child.wait().await?;
let stdout = out_task.await.unwrap_or_default();
if !status.success() {
let stderr = String::from_utf8_lossy(&all);
let message: Vec<&str> = stderr
.split(['\r', '\n'])
.map(str::trim)
.filter(|l| !l.is_empty() && !l.contains('%'))
.collect();
return Err(GitError::Failed {
args: what.to_owned(),
stderr: message.join("; "),
});
}
Ok(String::from_utf8_lossy(&stdout).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_gives_up_only_on_quiet_connections() {
let cmd = git(None);
let envs: HashMap<_, _> = cmd.as_std().get_envs().collect();
let env = |key: &str| envs.get(std::ffi::OsStr::new(key)).copied().flatten();
if std::env::var_os("GIT_HTTP_LOW_SPEED_LIMIT").is_none() {
assert_eq!(env("GIT_HTTP_LOW_SPEED_LIMIT"), Some("1".as_ref()));
}
}
}