use std::borrow::Cow;
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Read;
use std::num::NonZeroI32;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::time::{Duration, Instant};
use crate::error::{self, Error, Result};
use crate::host::Sha;
use crate::{ids, lock};
pub(crate) fn git_program() -> &'static std::ffi::OsStr {
static PROGRAM: std::sync::OnceLock<std::ffi::OsString> = std::sync::OnceLock::new();
PROGRAM
.get_or_init(|| {
std::env::var_os("PATH")
.and_then(|paths| {
std::env::split_paths(&paths)
.flat_map(|directory| {
#[cfg(windows)]
let names = ["git.exe", "git.cmd", "git.bat", "git"];
#[cfg(not(windows))]
let names = ["git"];
names.into_iter().map(move |name| directory.join(name))
})
.find(|path| executable(path))
.and_then(|path| std::path::absolute(path).ok())
})
.map_or_else(|| "git".into(), PathBuf::into_os_string)
})
.as_os_str()
}
fn executable(path: &Path) -> bool {
let Ok(metadata) = std::fs::metadata(path) else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
pub const TIMEOUT_ENV: &str = "ONEVCS_GIT_TIMEOUT";
pub const HOOK_TIMEOUT_ENV: &str = "ONEVCS_GIT_HOOK_TIMEOUT";
pub const DEFAULT_TIMEOUT_SECONDS: f64 = 600.0;
pub const DEFAULT_HOOK_TIMEOUT_SECONDS: f64 = 5400.0;
const EXIT_POLL: Duration = Duration::from_millis(10);
const READ_BUFFER: usize = 8192;
#[derive(Debug, Clone, Copy)]
enum Bound {
Ordinary,
Hooks,
}
impl Bound {
fn knob(self) -> (&'static str, f64) {
match self {
Bound::Ordinary => (TIMEOUT_ENV, DEFAULT_TIMEOUT_SECONDS),
Bound::Hooks => (HOOK_TIMEOUT_ENV, DEFAULT_HOOK_TIMEOUT_SECONDS),
}
}
}
const HOOK_RUNNING: &[&[&str]] = &[
&["clone"],
&["checkout"],
&["commit"],
&["merge"],
&["push"],
&["rebase"],
&["worktree", "add"],
];
#[derive(Debug, Clone)]
pub struct Output {
pub status: i32,
pub ended: Ended,
pub stdout: String,
pub stderr: String,
pub read_failures: Vec<String>,
}
impl Output {
pub fn ok(&self) -> bool {
self.status == 0
}
pub fn trimmed(&self) -> String {
self.stdout.trim().to_owned()
}
pub fn combined(&self) -> String {
format!("{}{}", self.stdout, self.stderr)
}
pub fn diagnostic(&self) -> String {
let stderr = self.stderr.trim();
let stdout = self.stdout.trim();
match (stderr.is_empty(), stdout.is_empty()) {
(false, _) => stderr.to_owned(),
(true, false) => stdout.to_owned(),
(true, true) => "<no output>".to_owned(),
}
}
}
pub fn run(args: &[&str], cwd: Option<&Path>) -> Result<Output> {
run_with_env(args, cwd, &[])
}
pub fn run_with_env(args: &[&str], cwd: Option<&Path>, env: &[(String, String)]) -> Result<Output> {
if let Some(recalled) = reads::recall(args, cwd, env) {
return Ok(recalled);
}
if let Some(output) = crate::recovery_cache::native(args, cwd, env) {
reads::remember(args, cwd, env, &output);
return Ok(output);
}
let reusable = crate::recovery_cache::query(args, cwd, env);
if let Some(output) = reusable
.as_ref()
.and_then(crate::recovery_cache::Query::read)
{
reads::remember(args, cwd, env, &output);
return Ok(output);
}
let hooked = runs_repository_hooks(args);
let shims = match cwd {
Some(cwd) if hooked => HookShims::cut(cwd)?,
_ => None,
};
let mut command = Command::new(git_program());
command.args(args);
let ran = bounded(
command,
cwd,
&shims
.as_ref()
.map(|shims| [env, &shims.env()].concat())
.unwrap_or_else(|| env.to_vec()),
if hooked {
Bound::Hooks
} else {
Bound::Ordinary
},
&format!("git {}", args.join(" ")),
|e| unstarted(&e, args, cwd),
);
drop(shims);
let ran = ran?;
let output = Output {
status: ran.status,
ended: ran.ended,
stdout: text(ran.stdout),
stderr: text(ran.stderr),
read_failures: ran.read_failures,
};
if let Some(reusable) = reusable {
reusable.write(&output);
}
reads::remember(args, cwd, env, &output);
Ok(output)
}
pub(crate) fn memoized<T>(read: impl FnOnce() -> T) -> T {
reads::enter();
let answered = read();
reads::leave();
answered
}
pub(crate) fn unremembered<T>(read: impl FnOnce() -> T) -> T {
reads::bypass(read)
}
mod reads {
use super::*;
type Key = (Option<PathBuf>, Vec<String>, Vec<(String, String)>);
thread_local! {
static DEPTH: Cell<usize> = const { Cell::new(0) };
static MEMO: RefCell<HashMap<Key, Output>> = RefCell::new(HashMap::new());
static SETTLED: RefCell<HashMap<Key, Output>> = RefCell::new(HashMap::new());
static BYPASSED: Cell<bool> = const { Cell::new(false) };
}
pub(super) fn enter() {
DEPTH.with(|depth| depth.set(depth.get() + 1));
}
pub(super) fn leave() {
DEPTH.with(|depth| {
depth.set(depth.get() - 1);
if depth.get() == 0 {
MEMO.with(|memo| memo.borrow_mut().clear());
crate::native_refs::clear();
SETTLED.with(|settled| settled.borrow_mut().clear());
}
});
}
pub(super) fn bypass<T>(read: impl FnOnce() -> T) -> T {
let before = BYPASSED.with(|bypassed| bypassed.replace(true));
crate::recovery_cache::clear();
let answered = read();
BYPASSED.with(|bypassed| bypassed.set(before));
answered
}
fn active() -> bool {
DEPTH.with(|depth| depth.get() > 0) && !BYPASSED.with(Cell::get)
}
fn settled(args: &[&str], output: &Output) -> bool {
match (args.first().copied(), args.get(1).copied()) {
(Some("cat-file"), Some("-e")) => output.status == 0,
(Some("merge-base"), Some("--is-ancestor")) => matches!(output.status, 0 | 1),
_ => false,
}
}
fn key(args: &[&str], cwd: Option<&Path>, env: &[(String, String)]) -> Key {
(
cwd.map(Path::to_path_buf),
args.iter().map(|arg| (*arg).to_owned()).collect(),
env.to_vec(),
)
}
fn remembered(args: &[&str]) -> bool {
match args.first().copied() {
Some("--exec-path") => args.len() == 1,
Some("config") => args == ["config", "--null", "--list", "--show-origin"],
Some("remote") => args.get(1) == Some(&"get-url"),
Some("cat-file") => args.get(1) == Some(&"-e"),
Some(
"rev-parse" | "show-ref" | "for-each-ref" | "rev-list" | "merge-base" | "log"
| "diff" | "check-ref-format",
) => true,
Some("symbolic-ref") => {
args.iter().skip(1).filter(|a| !a.starts_with('-')).count() == 1
}
_ => false,
}
}
fn harmless(args: &[&str]) -> bool {
matches!(args.first().copied(), Some("ls-remote" | "merge-tree"))
}
pub(super) fn recall(
args: &[&str],
cwd: Option<&Path>,
env: &[(String, String)],
) -> Option<Output> {
if !active() {
return None;
}
let asked = key(args, cwd, env);
if let Some(settled) = SETTLED.with(|settled| settled.borrow().get(&asked).cloned()) {
return Some(settled);
}
if remembered(args) {
return MEMO.with(|memo| memo.borrow().get(&asked).cloned());
}
if !harmless(args) {
MEMO.with(|memo| memo.borrow_mut().clear());
if args.first() != Some(&"status") {
crate::recovery_cache::clear();
}
}
None
}
pub(super) fn remember(
args: &[&str],
cwd: Option<&Path>,
env: &[(String, String)],
output: &Output,
) {
if active() && remembered(args) {
if settled(args, output) {
SETTLED.with(|settled| {
settled
.borrow_mut()
.insert(key(args, cwd, env), output.clone());
});
}
MEMO.with(|memo| {
memo.borrow_mut()
.insert(key(args, cwd, env), output.clone());
});
}
}
}
fn bounded(
mut command: Command,
cwd: Option<&Path>,
env: &[(String, String)],
class: Bound,
label: &str,
unspawnable: impl FnOnce(std::io::Error) -> Error,
) -> Result<Ran> {
let bound = bound_for(class)?;
let started = Instant::now();
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(directory) = cwd {
command.current_dir(git_path(directory));
}
for (key, value) in env {
command.env(key, value);
}
detach_process_group(&mut command);
let mut child = command.spawn().map_err(unspawnable)?;
let (ended, endings) = mpsc::channel();
let out_reader = PipeCapture::start(
child.stdout.take().expect("stdout was piped"),
ended.clone(),
);
let err_reader = PipeCapture::start(child.stderr.take().expect("stderr was piped"), ended);
let exited = wait_for_exit(&mut child, started, bound, &endings);
let Some(collected) = exited else {
terminate_group(&child);
let _ = child.kill();
let _ = child.wait();
let _ = out_reader.finish();
let _ = err_reader.finish();
let elapsed = started.elapsed().as_secs_f64();
let (knob, _) = class.knob();
return Err(Error::Invalid {
reason: format!(
"{label} timed out after {elapsed:.3}s (bound {bound}s; raise it with {knob})",
bound = bound.as_secs_f64()
),
});
};
let out = out_reader.finish();
let err = err_reader.finish();
let status = collected.map_err(|e| error::invalid(format!("cannot collect {label}: {e}")))?;
Ok(Ran {
status: status.code().unwrap_or(128),
ended: Ended::of(&status),
stdout: out.bytes,
stderr: err.bytes,
read_failures: [(STDOUT, out.failure), (STDERR, err.failure)]
.into_iter()
.filter_map(|(stream, failure)| {
failure.map(|failure| format!("{label}: reading its {stream} failed: {failure}"))
})
.collect(),
})
}
const STDOUT: &str = "standard output";
const STDERR: &str = "standard error";
pub(crate) struct PipeCapture {
stopping: Arc<AtomicBool>,
reader: std::thread::JoinHandle<Captured>,
}
pub(crate) struct Captured {
pub(crate) bytes: Vec<u8>,
pub(crate) failure: Option<String>,
}
impl PipeCapture {
pub(crate) fn start<R: PipeRead + Send + 'static>(
mut pipe: R,
ended: mpsc::Sender<()>,
) -> Self {
pipe.nonblocking();
let stopping = Arc::new(AtomicBool::new(false));
let requested = Arc::clone(&stopping);
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
let mut failure = None;
let mut chunk = [0_u8; READ_BUFFER];
loop {
let collected = requested.load(Ordering::Acquire);
match pipe.read_available(&mut chunk) {
Ok(0) => break,
Ok(read) => output.extend_from_slice(&chunk[..read]),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if collected {
break;
}
pipe.await_readable(EXIT_POLL);
}
Err(error) => {
failure = Some(error.to_string());
break;
}
}
}
let _ = ended.send(());
Captured {
bytes: output,
failure,
}
});
Self { stopping, reader }
}
pub(crate) fn finish(self) -> Captured {
self.stopping.store(true, Ordering::Release);
self.reader.join().unwrap_or_else(|_| Captured {
bytes: Vec::new(),
failure: Some("the reader of this stream ended abnormally".to_owned()),
})
}
}
#[cfg(unix)]
pub(crate) trait PipeRead: Read + std::os::fd::AsRawFd {
fn nonblocking(&mut self) {
let descriptor = self.as_raw_fd();
unsafe {
let flags = libc::fcntl(descriptor, libc::F_GETFL);
if flags >= 0 {
libc::fcntl(descriptor, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
}
}
fn read_available(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
self.read(buffer)
}
fn await_readable(&self, bound: Duration) {
let mut watched = libc::pollfd {
fd: self.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let bound = i32::try_from(bound.as_millis()).unwrap_or(i32::MAX);
unsafe {
libc::poll(&raw mut watched, 1, bound);
}
}
}
#[cfg(unix)]
impl<T: Read + std::os::fd::AsRawFd> PipeRead for T {}
#[cfg(windows)]
pub(crate) trait PipeRead: Read + std::os::windows::io::AsRawHandle {
fn nonblocking(&mut self) {}
fn read_available(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
use windows_sys::Win32::System::Pipes::PeekNamedPipe;
let mut available = 0_u32;
let succeeded = unsafe {
PeekNamedPipe(
self.as_raw_handle(),
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
&mut available,
std::ptr::null_mut(),
)
};
if succeeded == 0 {
return Err(std::io::Error::last_os_error());
}
if available == 0 {
return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock));
}
let readable = buffer.len().min(available as usize);
self.read(&mut buffer[..readable])
}
fn await_readable(&self, bound: Duration) {
std::thread::sleep(bound);
}
}
#[cfg(windows)]
impl<T: Read + std::os::windows::io::AsRawHandle> PipeRead for T {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ended {
Code(i32),
#[cfg_attr(
not(unix),
expect(
dead_code,
reason = "only Unix reports a terminating signal, so a build for any other \
target has no status to construct this from"
)
)]
Signal(i32),
}
impl Ended {
fn of(status: &ExitStatus) -> Self {
if let Some(code) = status.code() {
return Ended::Code(code);
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return Ended::Signal(signal);
}
}
Ended::Code(128)
}
}
impl std::fmt::Display for Ended {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Ended::Code(code) => write!(f, "exited with status {code}"),
Ended::Signal(signal) => match signal_name(*signal) {
Some(name) => write!(f, "was terminated by signal {signal} ({name})"),
None => write!(f, "was terminated by signal {signal}"),
},
}
}
}
fn signal_name(signal: i32) -> Option<&'static str> {
Some(match signal {
1 => "SIGHUP",
2 => "SIGINT",
3 => "SIGQUIT",
4 => "SIGILL",
6 => "SIGABRT",
8 => "SIGFPE",
9 => "SIGKILL",
11 => "SIGSEGV",
13 => "SIGPIPE",
14 => "SIGALRM",
15 => "SIGTERM",
_ => return None,
})
}
struct Ran {
status: i32,
ended: Ended,
stdout: Vec<u8>,
stderr: Vec<u8>,
read_failures: Vec<String>,
}
fn text(bytes: Vec<u8>) -> String {
String::from_utf8(bytes).unwrap_or_default()
}
fn prose(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
fn unstarted(error: &std::io::Error, args: &[&str], cwd: Option<&Path>) -> Error {
let missing = cwd.filter(|_| error.kind() == std::io::ErrorKind::NotFound);
match missing.filter(|directory| !git_path(directory).is_dir()) {
Some(directory) => error::invalid(format!(
"cannot run git {} in {}: that directory does not exist, so nothing ran there — the \
checkout or worktree it names has been removed",
args.join(" "),
directory.display()
)),
None => error::invalid(format!(
"cannot run git: {error} (is git installed and on PATH?)"
)),
}
}
#[cfg(windows)]
fn git_path(path: &Path) -> &Path {
dunce::simplified(path)
}
#[cfg(not(windows))]
fn git_path(path: &Path) -> &Path {
path
}
#[cfg(windows)]
fn git_location(value: &str) -> Cow<'_, str> {
let path = Path::new(value);
if path.is_absolute() {
git_path(path).to_string_lossy()
} else {
Cow::Borrowed(value)
}
}
#[cfg(not(windows))]
fn git_location(value: &str) -> Cow<'_, str> {
Cow::Borrowed(value)
}
pub fn checked(args: &[&str], cwd: Option<&Path>) -> Result<Output> {
checked_with_env(args, cwd, &[])
}
pub fn checked_with_env(
args: &[&str],
cwd: Option<&Path>,
env: &[(String, String)],
) -> Result<Output> {
let output = run_with_env(args, cwd, env)?;
if output.ok() {
return Ok(output);
}
Err(Error::Invalid {
reason: format!(
"git {} failed (exit {}): {}",
args.join(" "),
output.status,
output.diagnostic()
),
})
}
const ALTERNATE_OBJECTS: &str = "GIT_ALTERNATE_OBJECT_DIRECTORIES";
#[cfg(windows)]
const ALTERNATE_SEPARATOR: &str = ";";
#[cfg(not(windows))]
const ALTERNATE_SEPARATOR: &str = ":";
#[derive(Debug, Clone, Copy)]
pub struct Asked<'a> {
at: &'a Path,
borrowing: Option<&'a Path>,
}
impl<'a> From<&'a Path> for Asked<'a> {
fn from(at: &'a Path) -> Self {
Asked {
at,
borrowing: None,
}
}
}
impl<'a> From<&'a PathBuf> for Asked<'a> {
fn from(at: &'a PathBuf) -> Self {
Asked::from(at.as_path())
}
}
impl<'a> Asked<'a> {
pub fn borrowing(at: &'a Path, objects: Option<&'a Path>) -> Self {
Asked {
at,
borrowing: objects,
}
}
pub fn path(self) -> &'a Path {
self.at
}
fn env(self) -> Vec<(String, String)> {
self.reading_also(&[])
}
fn reading_also(self, own: &[PathBuf]) -> Vec<(String, String)> {
let mut stores: Vec<String> = own
.iter()
.map(|store| store.to_string_lossy().into_owned())
.collect();
let borrowed = self
.borrowing
.map(|store| store.to_string_lossy().into_owned())
.filter(|store| !store.contains(ALTERNATE_SEPARATOR));
stores.extend(borrowed);
if stores.is_empty() {
return Vec::new();
}
vec![(
ALTERNATE_OBJECTS.to_owned(),
stores.join(ALTERNATE_SEPARATOR),
)]
}
}
fn run_in(asked: Asked<'_>, args: &[&str]) -> Result<Output> {
run_with_env(args, Some(asked.at), &asked.env())
}
fn checked_in(asked: Asked<'_>, args: &[&str]) -> Result<Output> {
checked_with_env(args, Some(asked.at), &asked.env())
}
fn bound_for(bound: Bound) -> Result<Duration> {
let (name, default) = bound.knob();
let raw = std::env::var_os(name).map(|raw| raw.to_string_lossy().into_owned());
let value: f64 = match &raw {
None => default,
Some(raw) => raw.trim().parse().map_err(|_| Error::Invalid {
reason: format!("{name} must be a number of seconds, not {raw:?}"),
})?,
};
match Duration::try_from_secs_f64(value) {
Ok(held) if !held.is_zero() && Instant::now().checked_add(held).is_some() => Ok(held),
_ => Err(Error::Invalid {
reason: format!(
"{name} must be a finite number of seconds above zero, and short enough to be \
waited out from now, not {shown:?}",
shown = raw.unwrap_or_else(|| value.to_string()),
),
}),
}
}
pub fn check_bounds() -> Result<()> {
bound_for(Bound::Ordinary)?;
bound_for(Bound::Hooks)?;
Ok(())
}
pub fn has_commit<'a>(cwd: impl Into<Asked<'a>>, sha: &Sha) -> bool {
let cwd = cwd.into();
if let Some(answer) = crate::native_refs::has_commit(cwd.path(), &cwd.env(), &sha.0) {
return answer;
}
run_in(cwd, &["cat-file", "-e", &format!("{}^{{commit}}", sha.0)])
.map(|out| out.ok())
.unwrap_or(false)
}
pub fn refs_reach(cwd: &Path, commit: &str) -> bool {
run(
&["rev-list", "--count", commit, "--not", "--all", "--"],
Some(cwd),
)
.ok()
.filter(Output::ok)
.is_some_and(|out| out.trimmed() == "0")
}
pub fn tip(cwd: &Path, reference: &str) -> Option<String> {
if let Some(tip) = crate::native_refs::tip(cwd, reference) {
return tip;
}
run(
&["rev-parse", "--verify", &format!("{reference}^{{commit}}")],
Some(cwd),
)
.ok()
.filter(Output::ok)
.map(|out| out.trimmed())
}
fn runs_repository_hooks(args: &[&str]) -> bool {
HOOK_RUNNING
.iter()
.any(|command| args.len() >= command.len() && &args[..command.len()] == *command)
}
fn wait_for_exit(
child: &mut Child,
started: Instant,
bound: Duration,
endings: &mpsc::Receiver<()>,
) -> Option<std::io::Result<ExitStatus>> {
loop {
match child.try_wait() {
Ok(Some(status)) => return Some(Ok(status)),
Err(error) => return Some(Err(error)),
Ok(None) if started.elapsed() >= bound => return None,
Ok(None) => await_an_ending(endings, EXIT_POLL),
}
}
}
pub(crate) fn await_an_ending(endings: &mpsc::Receiver<()>, bound: Duration) {
if matches!(
endings.recv_timeout(bound),
Err(mpsc::RecvTimeoutError::Disconnected)
) {
std::thread::sleep(bound);
}
}
#[cfg(unix)]
pub(crate) fn detach_process_group(command: &mut Command) {
use std::os::unix::process::CommandExt;
command.process_group(0);
}
#[cfg(not(unix))]
pub(crate) fn detach_process_group(_command: &mut Command) {}
#[cfg(unix)]
pub(crate) fn terminate_group(child: &Child) {
unsafe {
libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL);
}
}
#[cfg(not(unix))]
pub(crate) fn terminate_group(child: &Child) {
let _ = child;
}
pub fn is_repo(path: &Path) -> bool {
if let Some(found) = crate::native_refs::is_repo(path) {
return found;
}
run(&["rev-parse", "--is-inside-work-tree"], Some(path))
.map(|out| out.ok() && out.trimmed() == "true")
.unwrap_or(false)
}
pub fn common_dir(cwd: &Path) -> Result<PathBuf> {
let value = checked(&["rev-parse", "--git-common-dir"], Some(cwd))?.trimmed();
let path = PathBuf::from(&value);
let path = if path.is_absolute() {
path
} else {
cwd.join(path)
};
Ok(path.canonicalize().unwrap_or(path))
}
pub fn remote_url(cwd: &Path, remote: &str) -> Result<String> {
let value = match crate::native_refs::remote_url(cwd, remote) {
Some(value) => value,
None => checked(&["remote", "get-url", remote], Some(cwd))?.trimmed(),
};
if !is_usable_remote(&value) {
return Err(Error::Invalid {
reason: format!("git remote {remote:?} returned an unusable URL"),
});
}
Ok(value)
}
pub fn is_usable_remote(value: &str) -> bool {
!value.trim().is_empty() && !value.contains(['\n', '\r'])
}
pub fn has_remote(cwd: &Path, remote: &str) -> bool {
if crate::native_refs::remote_url(cwd, remote).is_some() {
return true;
}
run(&["remote", "get-url", remote], Some(cwd))
.map(|out| out.ok())
.unwrap_or(false)
}
pub fn clone_sharing(source: &Path, dest: &Path, origin: &str, base: &str) -> Result<BaseCarried> {
let source_arg = git_path(source).to_string_lossy();
let dest_arg = git_path(dest).to_string_lossy();
let origin_arg = git_location(origin);
checked(
&["clone", "--shared", "--no-checkout", &source_arg, &dest_arg],
None,
)?;
checked(
&["remote", "set-url", "origin", origin_arg.as_ref()],
Some(dest),
)?;
let carried = carry_remote_refs(source, dest, base)?;
carry_hooks(source, dest)?;
Ok(carried)
}
pub fn carry_remote_refs(source: &Path, dest: &Path, base: &str) -> Result<BaseCarried> {
if has_remote(source, "origin") {
let source_arg = git_path(source).to_string_lossy();
checked(
&[
"fetch",
"--no-tags",
&source_arg,
"+refs/remotes/origin/*:refs/remotes/origin/*",
],
Some(dest),
)?;
}
checked(
&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
&format!("refs/remotes/origin/{base}"),
],
Some(dest),
)?;
carry_base(dest, base)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaseCarried {
Aligned,
Advanced,
Kept {
local: String,
origin: String,
},
}
fn carry_base(cwd: &Path, base: &str) -> Result<BaseCarried> {
let local_ref = format!("refs/heads/{base}");
let (Some(local), Some(origin)) = (
tip(cwd, &local_ref),
tip(cwd, &format!("refs/remotes/origin/{base}")),
) else {
return Ok(BaseCarried::Aligned);
};
if local == origin {
return Ok(BaseCarried::Aligned);
}
if !is_ancestor(cwd, &local, &origin)? {
return Ok(BaseCarried::Kept { local, origin });
}
if current_branch(cwd)? == base {
detach_head(cwd)?;
}
update_ref(cwd, &local_ref, &origin)?;
Ok(BaseCarried::Advanced)
}
fn carry_hooks(source: &Path, dest: &Path) -> Result<()> {
let Some(hooks) = carried_hooks(source)? else {
return Ok(());
};
checked(
&[
"config",
"core.hooksPath",
&git_path(&hooks).to_string_lossy(),
],
Some(dest),
)
.map(|_| ())
}
pub fn carried_hooks(source: &Path) -> Result<Option<PathBuf>> {
if let Some(configured) = configured_hooks(source)? {
return Ok(Some(configured));
}
Ok(source
.join(".githooks")
.is_dir()
.then(|| PathBuf::from(".githooks")))
}
pub fn configured_hooks(cwd: &Path) -> Result<Option<PathBuf>> {
let configured = run(&["config", "--get", "core.hooksPath"], Some(cwd))?.trimmed();
Ok((!configured.is_empty()).then(|| PathBuf::from(configured)))
}
pub fn retain_objects_for_borrowers(cwd: &Path) -> Result<()> {
#[cfg(unix)]
let common = crate::native_refs::layout(cwd)
.map(|(_, common)| common)
.map_or_else(|| common_dir(cwd), Ok)?;
#[cfg(not(unix))]
let common = common_dir(cwd)?;
let _configuration = lock::exclusive_at(&common.join("onevcs-retention.lock"))?;
checked(&["config", "gc.auto", "0"], Some(cwd))?;
checked(&["config", "gc.pruneExpire", "never"], Some(cwd))?;
Ok(())
}
pub fn objects_dir(cwd: &Path) -> Result<PathBuf> {
if let Some(objects) = crate::native_refs::objects_dir(cwd) {
return Ok(objects);
}
git_owned_path(cwd, "objects")
}
pub fn hooks_dir(cwd: &Path) -> Result<PathBuf> {
git_owned_path(cwd, "hooks")
}
fn git_owned_path(cwd: &Path, name: &str) -> Result<PathBuf> {
let value = checked(&["rev-parse", "--git-path", name], Some(cwd))?.trimmed();
let path = PathBuf::from(&value);
Ok(if path.is_absolute() {
path
} else {
cwd.join(path)
})
}
const COMMIT_MSG_HOOK: &str = "commit-msg";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessagePolicy {
Unstated,
Accepted,
Rejected {
status: NonZeroI32,
output: String,
},
}
pub fn message_policy(cwd: &Path, message: &str) -> Result<MessagePolicy> {
let hook = hooks_dir(cwd)?.join(COMMIT_MSG_HOOK);
if !is_executable(&hook)? {
return Ok(MessagePolicy::Unstated);
}
let file = git_owned_path(cwd, &format!("onevcs-{COMMIT_MSG_HOOK}-{}", ids::unique()))?;
std::fs::write(&file, format!("{}\n", message.trim_end())).map_err(error::at(
"write the message judged by the commit-msg hook to",
&file,
))?;
let mut command = Command::new(git_path(&hook));
command.arg(git_path(&file));
for name in local_env_vars()? {
command.env_remove(name);
}
let ran = bounded(
command,
Some(cwd),
&[],
Bound::Hooks,
&format!("the {COMMIT_MSG_HOOK} hook at {}", hook.display()),
|e| {
error::invalid(format!(
"cannot run the {COMMIT_MSG_HOOK} hook at {}: {e}",
hook.display()
))
},
);
let _ = std::fs::remove_file(&file);
let ran = ran?;
Ok(match NonZeroI32::new(ran.status) {
None => MessagePolicy::Accepted,
Some(status) => MessagePolicy::Rejected {
status,
output: format!("{}{}", prose(&ran.stdout), prose(&ran.stderr)),
},
})
}
#[cfg(unix)]
fn is_executable(path: &Path) -> Result<bool> {
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(path) {
Ok(meta) => Ok(meta.is_file() && meta.permissions().mode() & 0o111 != 0),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(unaskable(path, &error)),
}
}
#[cfg(not(unix))]
fn is_executable(path: &Path) -> Result<bool> {
match std::fs::metadata(path) {
Ok(meta) => Ok(meta.is_file()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(unaskable(path, &error)),
}
}
fn unaskable(path: &Path, error: &std::io::Error) -> Error {
error::invalid(format!(
"cannot tell whether the {COMMIT_MSG_HOOK} hook at {} is one git would run: {error}",
path.display()
))
}
pub const FETCH_LOCK: &str = "onevcs-fetch.lock";
pub fn fetch(cwd: &Path, remote: &str) -> Result<()> {
let _turn = lock::exclusive_at(&common_dir(cwd)?.join(FETCH_LOCK))?;
checked(&["fetch", remote, "--prune"], Some(cwd)).map(|_| ())
}
pub fn default_branch(cwd: &Path, remote: &str) -> Result<String> {
if let Some(branch) = tracked_head(cwd, remote)? {
return Ok(branch);
}
if let Some(branch) = advertised_head(cwd, remote)? {
return Ok(branch);
}
let mut candidates: Vec<String> = checked(
&[
"for-each-ref",
"--format=%(refname:strip=3)",
&format!("refs/remotes/{remote}"),
],
Some(cwd),
)?
.stdout
.lines()
.filter(|line| !line.is_empty() && *line != "HEAD")
.map(str::to_owned)
.collect();
candidates.sort();
candidates.dedup();
if candidates.len() == 1 {
return Ok(candidates.remove(0));
}
if candidates.is_empty() {
let current = run(&["symbolic-ref", "--quiet", "--short", "HEAD"], Some(cwd))?.trimmed();
if !current.is_empty() {
return Ok(current);
}
}
let detail = if candidates.is_empty() {
"none".to_owned()
} else {
candidates.join(", ")
};
Err(Error::Invalid {
reason: format!(
"cannot determine the default branch of remote {remote:?}: {remote}/HEAD is missing \
or stale, the remote advertises no HEAD of its own, and the plausible remote \
branches are {detail}; pass an explicit --base"
),
})
}
fn tracked_head(cwd: &Path, remote: &str) -> Result<Option<String>> {
let reference = format!("refs/remotes/{remote}/HEAD");
if let Some(target) = crate::native_refs::symbolic(cwd, &reference) {
return Ok(target
.and_then(|target| {
target
.strip_prefix(&format!("refs/remotes/{remote}/"))
.map(str::to_owned)
})
.filter(|branch| ref_exists(cwd, &format!("refs/remotes/{remote}/{branch}"))));
}
let named = run(
&[
"symbolic-ref",
"--short",
&format!("refs/remotes/{remote}/HEAD"),
],
Some(cwd),
)?
.trimmed();
let Some(branch) = named.strip_prefix(&format!("{remote}/")) else {
return Ok(None);
};
Ok(ref_exists(cwd, &format!("refs/remotes/{named}")).then(|| branch.to_owned()))
}
fn advertised_head(cwd: &Path, remote: &str) -> Result<Option<String>> {
let listing = run(&["ls-remote", "--symref", remote, "HEAD"], Some(cwd))?;
if !listing.ok() {
return Ok(None);
}
Ok(listing.stdout.lines().find_map(|line| {
line.strip_prefix("ref: refs/heads/")
.and_then(|rest| rest.split('\t').next())
.filter(|branch| !branch.is_empty())
.map(str::to_owned)
}))
}
pub fn ref_exists(cwd: &Path, reference: &str) -> bool {
if let Some(tip) = crate::native_refs::tip(cwd, reference) {
return tip.is_some();
}
run(&["show-ref", "--verify", "--quiet", reference], Some(cwd))
.map(|out| out.ok())
.unwrap_or(false)
}
pub fn branch_exists(cwd: &Path, branch: &str) -> bool {
ref_exists(cwd, &format!("refs/heads/{branch}"))
}
pub fn head_sha(cwd: &Path) -> Result<String> {
Ok(checked(&["rev-parse", "HEAD"], Some(cwd))?.trimmed())
}
pub fn detach_head(cwd: &Path) -> Result<()> {
let head = head_sha(cwd)?;
checked(&["update-ref", "--no-deref", "HEAD", &head], Some(cwd)).map(|_| ())
}
pub fn current_branch(cwd: &Path) -> Result<String> {
Ok(checked(&["rev-parse", "--abbrev-ref", "HEAD"], Some(cwd))?.trimmed())
}
pub fn checked_out_branch(worktree: &Path) -> Result<String> {
let dot_git = worktree.join(".git");
let admin = if dot_git.is_dir() {
Some(dot_git)
} else {
std::fs::read_to_string(&dot_git).ok().and_then(|text| {
text.trim()
.strip_prefix("gitdir: ")
.map(|dir| worktree.join(dir))
})
};
let named = admin
.and_then(|admin| std::fs::read_to_string(admin.join("HEAD")).ok())
.and_then(|head| {
let head = head.trim();
match head.strip_prefix("ref: ") {
Some(reference) => reference.strip_prefix("refs/heads/").map(str::to_owned),
None => ObjectId::parse(head).map(|_| "HEAD".to_owned()),
}
});
match named {
Some(branch) => Ok(branch),
None => unremembered(|| current_branch(worktree)),
}
}
pub fn committer_date<'a>(cwd: impl Into<Asked<'a>>, commit: &str) -> Option<String> {
run_in(
cwd.into(),
&[
"show",
"-s",
"--format=%cI",
&format!("{commit}^{{commit}}"),
],
)
.ok()
.filter(Output::ok)
.map(|output| output.trimmed())
.filter(|date| !date.is_empty())
}
pub fn branches(cwd: &Path) -> Result<Vec<String>> {
if let Some(heads) = crate::native_refs::heads(cwd) {
return Ok(heads.into_iter().map(|(branch, _)| branch).collect());
}
Ok(checked(
&["for-each-ref", "--format=%(refname:short)", "refs/heads"],
Some(cwd),
)?
.stdout
.lines()
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect())
}
pub fn remote_branches(cwd: &Path, remote: &str) -> Result<Vec<String>> {
if let Some(heads) = crate::native_refs::remote_heads(cwd, remote) {
return Ok(heads.into_keys().collect());
}
Ok(checked(
&[
"for-each-ref",
"--format=%(refname:lstrip=3)",
&format!("refs/remotes/{remote}"),
],
Some(cwd),
)?
.stdout
.lines()
.filter(|line| !line.is_empty() && *line != "HEAD")
.map(str::to_owned)
.collect())
}
pub fn unpublished_branches(cwd: &Path) -> Result<Vec<String>> {
Ok(
unpublished_branches_among(cwd, |_| true, &BTreeSet::new(), |_| None)?
.into_iter()
.map(|(branch, _)| branch)
.collect(),
)
}
pub fn unpublished_branches_among(
cwd: &Path,
keep: impl Fn(&str) -> bool,
listed_anyway: &BTreeSet<String>,
based_on: impl Fn(&str) -> Option<String>,
) -> Result<Vec<(String, String)>> {
let native = crate::native_refs::heads(cwd).zip(crate::native_refs::remote_tips(cwd, "origin"));
let (heads, origin_tips) = if let Some((heads, origin)) = native {
(heads, origin)
} else {
let listing = checked(
&[
"for-each-ref",
"--format=%(refname)%00%(refname:short)%00%(objectname)",
"refs/heads",
"refs/remotes/origin",
],
Some(cwd),
)?;
let mut heads: Vec<(String, String)> = Vec::new();
let mut origin_tips: BTreeSet<String> = BTreeSet::new();
for line in listing.stdout.lines() {
let mut fields = line.split('\0');
let (Some(full), Some(short), Some(tip)) =
(fields.next(), fields.next(), fields.next())
else {
continue;
};
if full.starts_with("refs/heads/") {
heads.push((short.to_owned(), tip.to_owned()));
} else if full.starts_with("refs/remotes/origin/") {
origin_tips.insert(tip.to_owned());
}
}
(heads, origin_tips)
};
let local: BTreeMap<String, String> = heads.iter().cloned().collect();
let mut unpublished = Vec::new();
for (branch, tip) in heads {
if !keep(&branch) {
continue;
}
if listed_anyway.contains(&branch) {
unpublished.push((branch, tip));
continue;
}
if origin_tips.contains(&tip) {
continue;
}
let base = based_on(&branch).and_then(|base| local.get(&base).cloned());
if base.as_deref() == Some(tip.as_str()) {
continue;
}
let carried: Vec<&str> = base.iter().map(String::as_str).collect();
if unpublished_ahead(cwd, &tip, &carried)? > 0 {
unpublished.push((branch, tip));
}
}
Ok(unpublished)
}
pub fn unpublished_heads(cwd: &Path) -> Result<BTreeSet<String>> {
let listing = checked(
&[
"for-each-ref",
"--format=%(objectname)",
"refs/remotes/origin",
],
Some(cwd),
)?;
let mut unreached = Vec::new();
for line in listing.stdout.lines().filter(|line| !line.is_empty()) {
let tip = ObjectId::parse(line).ok_or_else(|| {
error::invalid(format!(
"`git for-each-ref` in {} printed {line:?} where a remote-tracking tip belongs",
cwd.display()
))
})?;
unreached.push(format!("--no-merged={}", tip.as_str()));
}
unreached.sort();
unreached.dedup();
let mut args: Vec<&str> = vec!["for-each-ref", "--format=%(refname:short)"];
args.extend(unreached.iter().map(String::as_str));
args.push("refs/heads");
Ok(checked(&args, Some(cwd))?
.stdout
.lines()
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect())
}
pub fn unpublished_ahead(cwd: &Path, reference: &str, carried: &[&str]) -> Result<u64> {
let held: Vec<&str> = carried
.iter()
.copied()
.filter(|name| tip(cwd, name).is_some())
.collect();
let native = crate::native_refs::remote_tips(cwd, "origin").zip(
if ObjectId::parse(reference).is_some() {
Some(reference.to_owned())
} else {
crate::native_refs::tip(cwd, reference).flatten()
},
);
let immutable_query = native.is_some();
let immutable;
let mut args = if let Some((mut origin, tip)) = native {
origin.extend(held.iter().filter_map(|name| crate::git::tip(cwd, name)));
immutable = std::iter::once(tip).chain(origin).collect::<Vec<_>>();
let mut args = vec!["rev-list", "--count", immutable[0].as_str(), "--not"];
args.extend(immutable[1..].iter().map(String::as_str));
args
} else {
vec![
"rev-list",
"--count",
reference,
"--not",
"--remotes=origin",
]
};
if !immutable_query {
args.extend_from_slice(&held);
}
args.push("--");
let counted = run(&args, Some(cwd))?;
if !counted.ok() {
if tip(cwd, reference).is_none() {
return Ok(0);
}
return Err(error::invalid(format!(
"git could not count what {reference:?} holds in {}, and a count nobody got is \
not a count of none: {}",
cwd.display(),
counted.stderr.trim(),
)));
}
let answer = counted.trimmed();
answer.parse::<u64>().map_err(|_| {
error::invalid(format!(
"git counted the commits {reference:?} holds and answered {answer:?}, which is not \
a number of commits"
))
})
}
pub(crate) fn plainly_a_ref_name(branch: &str) -> bool {
!branch.is_empty()
&& !branch.ends_with('.')
&& !branch.contains("..")
&& !branch.contains("@{")
&& !branch.bytes().any(|byte| {
byte <= b' '
|| byte == 0x7f
|| matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
})
&& branch
.split('/')
.all(|part| !part.is_empty() && !part.starts_with('.') && !part.ends_with(".lock"))
}
pub fn is_valid_branch_name(branch: &str) -> bool {
if branch.is_empty() || branch.starts_with('-') {
return false;
}
if plainly_a_ref_name(branch) {
return true;
}
static ACCEPTED: Mutex<Option<HashMap<String, bool>>> = Mutex::new(None);
let mut accepted = ACCEPTED
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let accepted = accepted.get_or_insert_with(HashMap::new);
if let Some(known) = accepted.get(branch) {
return *known;
}
let valid = run(&["check-ref-format", &format!("refs/heads/{branch}")], None)
.map(|out| out.ok())
.unwrap_or(false);
accepted.insert(branch.to_owned(), valid);
valid
}
pub fn is_dirty(cwd: &Path) -> Result<bool> {
Ok(!checked(&["status", "--porcelain"], Some(cwd))?
.trimmed()
.is_empty())
}
pub fn add_all(cwd: &Path) -> Result<()> {
checked(&["add", "-A"], Some(cwd)).map(|_| ())
}
pub fn commit(cwd: &Path, message: &str) -> Result<String> {
checked(&["commit", "-m", message], Some(cwd))?;
head_sha(cwd)
}
pub fn commit_empty(cwd: &Path, message: &str) -> Result<String> {
checked(&["commit", "--allow-empty", "-m", message], Some(cwd))?;
head_sha(cwd)
}
pub fn commit_empty_on_branch(cwd: &Path, branch: &str, message: &str) -> Result<String> {
let reference = format!("refs/heads/{branch}");
let parent = tip(cwd, &reference).ok_or_else(|| {
crate::error::invalid(format!("{} has no branch {branch:?}", cwd.display()))
})?;
let tree = format!("{parent}^{{tree}}");
let commit = checked(
&["commit-tree", &tree, "-p", &parent, "-m", message],
Some(cwd),
)?
.trimmed();
checked(&["update-ref", &reference, &commit, &parent], Some(cwd))?;
Ok(commit)
}
#[derive(Debug, Clone)]
pub struct CommitMessage {
pub sha: String,
pub message: String,
}
pub fn log_messages<'a>(
cwd: impl Into<Asked<'a>>,
base: &str,
branch: &str,
) -> Result<Vec<CommitMessage>> {
let cwd = cwd.into();
let base_tip = crate::native_refs::tip_with_objects(cwd.at, base, cwd.borrowing).flatten();
let branch_tip = crate::native_refs::tip_with_objects(cwd.at, branch, cwd.borrowing).flatten();
let base = base_tip.as_deref().unwrap_or(base);
let branch = branch_tip.as_deref().unwrap_or(branch);
let output = checked_in(
cwd,
&[
"log",
"--reverse",
"--format=%H%x00%B%x00%x1e",
&format!("{base}..{branch}"),
"--",
],
)?;
Ok(output
.stdout
.split('\u{1e}')
.filter_map(|record| {
let value = record.trim_matches(|c| c == '\n' || c == '\0');
let (sha, message) = value.split_once('\0')?;
Some(CommitMessage {
sha: sha.to_owned(),
message: message.trim_end().to_owned(),
})
})
.collect())
}
pub fn first_parent_line<'a>(
cwd: impl Into<Asked<'a>>,
base: &str,
branch: &str,
) -> Result<Vec<String>> {
let cwd = cwd.into();
let base_tip = crate::native_refs::tip_with_objects(cwd.at, base, cwd.borrowing).flatten();
let branch_tip = crate::native_refs::tip_with_objects(cwd.at, branch, cwd.borrowing).flatten();
let base = base_tip.as_deref().unwrap_or(base);
let branch = branch_tip.as_deref().unwrap_or(branch);
let output = checked_in(
cwd,
&[
"rev-list",
"--first-parent",
&format!("{base}..{branch}"),
"--",
],
)?;
Ok(output.stdout.lines().map(str::to_owned).collect())
}
pub fn trees_differ<'a>(cwd: impl Into<Asked<'a>>, base: &str, branch: &str) -> Result<bool> {
let output = run_in(cwd.into(), &["diff", "--quiet", base, branch, "--"])?;
match output.status {
0 => Ok(false),
1 => Ok(true),
_ => Err(Error::Invalid {
reason: format!("git diff {base} {branch} failed: {}", output.diagnostic()),
}),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Lines {
pub added: u64,
pub removed: u64,
}
pub fn line_change<'a>(cwd: impl Into<Asked<'a>>, from: &str, to: &str) -> Result<Lines> {
let listed = checked_in(
cwd.into(),
&["diff", "--numstat", "--no-renames", from, to, "--"],
)?;
let mut counted = Lines::default();
for line in listed.stdout.lines().filter(|line| !line.trim().is_empty()) {
let unreadable = || Error::Invalid {
reason: format!(
"git diff --numstat {from} {to} printed {line:?}, which is not a count of lines \
and a path"
),
};
let (added, rest) = line.split_once('\t').ok_or_else(unreadable)?;
let (removed, _path) = rest.split_once('\t').ok_or_else(unreadable)?;
if added == "-" && removed == "-" {
continue;
}
let added: u64 = added.parse().map_err(|_| unreadable())?;
let removed: u64 = removed.parse().map_err(|_| unreadable())?;
counted.added = counted.added.saturating_add(added);
counted.removed = counted.removed.saturating_add(removed);
}
Ok(counted)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shape {
pub tree: String,
pub parents: String,
pub committed: String,
pub subject: String,
}
pub fn shape_of(cwd: &Path, reference: &str) -> Result<Shape> {
let immutable = crate::native_refs::tip(cwd, reference).flatten();
let reference = immutable.as_deref().unwrap_or(reference);
let printed = checked(
&[
"log",
"-1",
"--format=%T%x00%P%x00%cI%x00%s",
reference,
"--",
],
Some(cwd),
)?;
let mut fields = printed.stdout.trim_end_matches('\n').splitn(4, '\0');
let (Some(tree), Some(parents), Some(committed), Some(subject)) =
(fields.next(), fields.next(), fields.next(), fields.next())
else {
return Err(Error::Invalid {
reason: format!(
"git log -1 {reference} in {} printed no tree, parents, date, and subject: {:?}",
cwd.display(),
printed.stdout
),
});
};
Ok(Shape {
tree: tree.to_owned(),
parents: parents.to_owned(),
committed: committed.to_owned(),
subject: subject.to_owned(),
})
}
pub fn is_ancestor<'a>(
cwd: impl Into<Asked<'a>>,
ancestor: &str,
descendant: &str,
) -> Result<bool> {
let cwd = cwd.into();
if let Some(answer) =
crate::native_refs::is_ancestor(cwd.path(), &cwd.env(), ancestor, descendant)
{
return Ok(answer);
}
let output = run_in(cwd, &["merge-base", "--is-ancestor", ancestor, descendant])?;
match output.status {
0 => Ok(true),
1 => Ok(false),
_ => Err(Error::Invalid {
reason: format!("git merge-base failed: {}", output.diagnostic()),
}),
}
}
pub fn known_to_reach<'a>(
cwd: impl Into<Asked<'a>>,
ancestor: &str,
descendant: &str,
) -> Result<bool> {
let cwd = cwd.into();
for revision in [ancestor, descendant] {
if !has_commit(cwd, &Sha(revision.to_owned())) {
return Ok(false);
}
}
is_ancestor(cwd, ancestor, descendant)
}
pub fn merge_base<'a>(
cwd: impl Into<Asked<'a>>,
first: &str,
second: &str,
) -> Result<Option<String>> {
let output = run_in(cwd.into(), &["merge-base", first, second])?;
match output.status {
0 => Ok(Some(output.trimmed()).filter(|sha| !sha.is_empty())),
1 => Ok(None),
_ => Err(Error::Invalid {
reason: format!(
"git merge-base {first} {second} failed: {}",
output.diagnostic()
),
}),
}
}
fn counted_files(cwd: Asked<'_>, from: &str, to: &str) -> Result<usize> {
let summary = checked_in(
cwd,
&["diff", "--shortstat", "--no-renames", from, to, "--"],
)?
.trimmed();
if summary.is_empty() {
return Ok(0);
}
summary
.split_once(" file")
.map(|(count, _)| count.trim())
.and_then(|count| count.parse().ok())
.ok_or_else(|| Error::Invalid {
reason: format!(
"git diff --shortstat {from} {to} did not begin with a count of files: {summary}"
),
})
}
pub fn known_to_carry_changes<'a>(
cwd: impl Into<Asked<'a>>,
base: &str,
fork: &str,
commit: &str,
) -> Result<bool> {
let cwd = cwd.into();
let listed = checked_in(
cwd,
&[
"diff",
"--name-only",
"--no-renames",
"-z",
fork,
commit,
"--",
],
)?;
let touched: Vec<String> = listed
.stdout
.split('\0')
.filter(|path| !path.is_empty())
.map(|path| format!(":(literal){path}"))
.collect();
if touched.len() != counted_files(cwd, fork, commit)? {
return Ok(!trees_differ(cwd, base, commit)?);
}
if touched.is_empty() {
return Ok(true);
}
let mut args = vec!["diff", "--quiet", commit, base, "--"];
args.extend(touched.iter().map(String::as_str));
let output = run_in(cwd, &args)?;
match output.status {
0 => Ok(true),
1 => Ok(false),
_ => Err(Error::Invalid {
reason: format!(
"git diff {commit} {base} over {} paths failed: {}",
touched.len(),
output.diagnostic()
),
}),
}
}
pub(crate) fn already_integrates<'a>(
cwd: impl Into<Asked<'a>>,
base: &str,
commit: &str,
) -> Result<bool> {
let cwd = cwd.into();
let reusable = crate::recovery_cache::query(
&["merge-tree", "--write-tree", base, commit],
Some(cwd.path()),
&cwd.env(),
);
if let Some(output) = reusable
.as_ref()
.and_then(crate::recovery_cache::Query::read)
{
if output.status == 1 {
return Ok(false);
}
let tree =
checked_in(cwd, &["rev-parse", "--verify", &format!("{base}^{{tree}}")])?.trimmed();
return Ok(output.stdout.lines().next() == Some(tree.as_str()));
}
let scratch = tempfile::tempdir().map_err(|failure| Error::Invalid {
reason: format!("could not create a scratch object store for landing detection: {failure}"),
})?;
let mut env = vec![(
"GIT_OBJECT_DIRECTORY".to_owned(),
scratch.path().to_string_lossy().into_owned(),
)];
env.extend(cwd.reading_also(&[objects_dir(cwd.path())?]));
let merged = run_with_env(
&["merge-tree", "--write-tree", base, commit],
Some(cwd.path()),
&env,
)?;
if let Some(reusable) = reusable {
reusable.write(&merged);
}
match merged.status {
0 => {
let tree = checked_with_env(
&["rev-parse", "--verify", &format!("{base}^{{tree}}")],
Some(cwd.path()),
&env,
)?
.trimmed();
Ok(merged.stdout.lines().next() == Some(tree.as_str()))
}
1 => Ok(false),
_ => Err(Error::Invalid {
reason: format!(
"git merge-tree --write-tree {base} {commit} failed: {}",
merged.diagnostic()
),
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Integrated {
Settled,
Conflicted(Conflict),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Conflict {
paths: Vec<String>,
hunks: String,
}
impl Conflict {
pub fn paths(&self) -> &[String] {
&self.paths
}
pub fn hunks(&self) -> &str {
&self.hunks
}
}
fn conflict_in(cwd: &Path) -> Result<Option<Conflict>> {
let unmerged = run(&["diff", "--name-only", "-z", "--diff-filter=U"], Some(cwd))?;
if !unmerged.ok() {
return Ok(None);
}
let paths: Vec<String> = unmerged
.stdout
.split('\0')
.filter(|path| !path.is_empty())
.map(str::to_owned)
.collect();
if paths.is_empty() {
return Ok(None);
}
let hunks = run(&["diff", "--diff-filter=U"], Some(cwd))
.ok()
.filter(Output::ok)
.map(|out| out.stdout)
.unwrap_or_default();
Ok(Some(Conflict { paths, hunks }))
}
pub fn rebase_onto(cwd: &Path, onto: &str, upstream: &str, branch: &str) -> Result<Integrated> {
let replayed = run(&["rebase", "--onto", onto, upstream, branch], Some(cwd))?;
if replayed.ok() {
return Ok(Integrated::Settled);
}
let conflict = conflict_in(cwd)?;
run(&["rebase", "--abort"], Some(cwd))?;
if let Some(conflict) = conflict {
return Ok(Integrated::Conflicted(conflict));
}
Err(Error::Invalid {
reason: format!(
"git rebase --onto {onto} {upstream} {branch} failed: {}",
replayed.diagnostic()
),
})
}
pub fn committed_at(cwd: &Path, reference: &str) -> Option<u64> {
let immutable = crate::native_refs::tip(cwd, reference).flatten();
let reference = immutable.as_deref().unwrap_or(reference);
run(&["log", "-1", "--format=%ct", reference, "--"], Some(cwd))
.ok()
.filter(Output::ok)
.and_then(|out| out.trimmed().parse().ok())
}
pub fn worktree_add(cwd: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
let path = git_path(path).to_string_lossy();
checked(&["worktree", "add", "-b", branch, &path, base], Some(cwd)).map(|_| ())
}
pub fn worktree_add_existing(cwd: &Path, path: &Path, branch: &str) -> Result<()> {
let path = git_path(path).to_string_lossy();
checked(&["worktree", "add", &path, branch], Some(cwd)).map(|_| ())
}
pub fn worktree_add_detached(cwd: &Path, path: &Path, reference: &str) -> Result<()> {
let path = git_path(path).to_string_lossy();
checked(
&["worktree", "add", "--detach", &path, reference],
Some(cwd),
)
.map(|_| ())
}
pub fn checkout_new(worktree: &Path, branch: &str, base: &str) -> Result<()> {
checked(&["checkout", "-q", "-b", branch, base], Some(worktree)).map(|_| ())
}
pub fn checkout_existing(worktree: &Path, branch: &str) -> Result<()> {
checked(&["checkout", "-q", branch], Some(worktree)).map(|_| ())
}
pub fn checkout_detached(worktree: &Path) -> Result<()> {
checked(&["checkout", "-q", "--detach"], Some(worktree)).map(|_| ())
}
pub fn reset_hard(worktree: &Path, reference: &str) -> Result<()> {
checked(&["reset", "-q", "--hard", reference], Some(worktree)).map(|_| ())
}
pub fn clean_untracked(worktree: &Path) -> Result<()> {
checked(&["clean", "-q", "-f", "-d"], Some(worktree)).map(|_| ())
}
pub fn worktree_remove(cwd: &Path, path: &Path) -> Result<()> {
let path = git_path(path).to_string_lossy();
run(&["worktree", "remove", "--force", &path], Some(cwd)).map(|_| ())
}
pub fn worktree_prune(cwd: &Path) -> Result<()> {
run(&["worktree", "prune", "--expire", "now"], Some(cwd)).map(|_| ())
}
pub fn merge_would_conflict(cwd: &Path, base: &str, branch: &str) -> Result<bool> {
let merged = run(&["merge-tree", "--write-tree", base, branch], Some(cwd))?;
match merged.status {
0 => Ok(false),
1 => Ok(true),
_ => Err(Error::Invalid {
reason: format!(
"git merge-tree --write-tree {base} {branch} failed: {}",
merged.diagnostic()
),
}),
}
}
pub fn merge_into_branch(cwd: &Path, reference: &str, message: &str) -> Result<Integrated> {
let integrated = merge_leaving_conflict(cwd, reference, message)?;
if let Integrated::Conflicted(_) = integrated {
run(&["merge", "--abort"], Some(cwd))?;
}
Ok(integrated)
}
pub fn merge_leaving_conflict(cwd: &Path, reference: &str, message: &str) -> Result<Integrated> {
let merged = run(&["merge", "--no-edit", "-m", message, reference], Some(cwd))?;
if merged.ok() {
return Ok(Integrated::Settled);
}
let Some(conflict) = conflict_in(cwd)? else {
return Err(Error::Invalid {
reason: format!("git merge {reference} failed: {}", merged.diagnostic()),
});
};
Ok(Integrated::Conflicted(conflict))
}
pub fn merge_commit_leaving_conflict(
cwd: &Path,
reference: &str,
message: &str,
) -> Result<Integrated> {
let merged = run(
&["merge", "--no-ff", "--no-edit", "-m", message, reference],
Some(cwd),
)?;
if merged.ok() {
return Ok(Integrated::Settled);
}
let Some(conflict) = conflict_in(cwd)? else {
return Err(Error::Invalid {
reason: format!("git merge {reference} failed: {}", merged.diagnostic()),
});
};
Ok(Integrated::Conflicted(conflict))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Merging {
pub merging: String,
pub unmerged: Vec<String>,
}
pub fn merge_in_progress(worktree: &Path) -> Result<Option<Merging>> {
reads::bypass(|| {
let Some(merging) = tip(worktree, "MERGE_HEAD") else {
return Ok(None);
};
let unmerged = conflict_in(worktree)?
.map(|conflict| conflict.paths)
.unwrap_or_default();
Ok(Some(Merging { merging, unmerged }))
})
}
pub fn abort_merge(worktree: &Path) -> bool {
if !worktree.is_dir() || reads::bypass(|| tip(worktree, "MERGE_HEAD")).is_none() {
return false;
}
let aborted = run(&["merge", "--abort"], Some(worktree)).is_ok_and(|out| out.ok());
if !aborted || reads::bypass(|| tip(worktree, "MERGE_HEAD")).is_some() {
let _ = run(&["reset", "-q", "--hard", "HEAD"], Some(worktree));
}
true
}
pub fn merge_squash(cwd: &Path, reference: &str, message: &str) -> Result<Option<String>> {
checked(&["merge", "--squash", reference], Some(cwd))?;
if !is_dirty(cwd)? {
return Ok(None);
}
checked(&["commit", "-m", message], Some(cwd))?;
head_sha(cwd).map(Some)
}
pub fn tags_containing(cwd: &Path, commit: &str) -> Result<Vec<String>> {
let output = checked(&["tag", &format!("--contains={commit}")], Some(cwd))?;
Ok(output
.stdout
.lines()
.map(str::trim)
.filter(|tag| !tag.is_empty())
.map(str::to_owned)
.collect())
}
pub fn merge_ff_only(cwd: &Path, reference: &str) -> Result<()> {
checked(&["merge", "--ff-only", reference], Some(cwd)).map(|_| ())
}
#[derive(Debug, Clone)]
pub enum Pushed {
Accepted {
output: String,
},
Refused {
output: String,
refs: Vec<String>,
diagnostics: PushDiagnostics,
},
}
#[derive(Debug, Clone)]
pub struct PushDiagnostics {
pub ended: Ended,
pub read_failures: Vec<String>,
}
impl Pushed {
pub fn accepted(&self) -> bool {
matches!(self, Pushed::Accepted { .. })
}
pub fn output(&self) -> &str {
match self {
Pushed::Accepted { output } | Pushed::Refused { output, .. } => output,
}
}
pub fn refused_branch(&self, branch: &str) -> bool {
let reference = format!("refs/heads/{branch}");
match self {
Pushed::Accepted { .. } => false,
Pushed::Refused { refs, .. } => refs.contains(&reference),
}
}
pub fn diagnostics(&self) -> Option<&PushDiagnostics> {
match self {
Pushed::Accepted { .. } => None,
Pushed::Refused { diagnostics, .. } => Some(diagnostics),
}
}
pub fn refusal(&self) -> Option<&str> {
let Pushed::Refused { output, .. } = self else {
return None;
};
output
.lines()
.find(|line| line.starts_with("!\t"))
.and_then(|line| line.split('\t').nth(2))
}
}
pub fn push(cwd: &Path, branch: &str, remote: &str, env: &[(String, String)]) -> Result<Pushed> {
push_replacing(cwd, branch, remote, None, env)
}
pub fn push_replacing(
cwd: &Path,
branch: &str,
remote: &str,
replacing: Option<&str>,
env: &[(String, String)],
) -> Result<Pushed> {
let lease = replacing.map(|seen| format!("--force-with-lease={branch}:{seen}"));
let mut args = vec!["push", "--porcelain", remote, branch];
if let Some(lease) = lease.as_deref() {
args.insert(1, lease);
}
pushed(&args, cwd, env)
}
pub fn push_preserving(cwd: &Path, branch: &str, remote: &str) -> Result<Pushed> {
pushed(
&[
"push",
"--porcelain",
"--no-verify",
remote,
&format!("refs/heads/{branch}:refs/heads/{branch}"),
],
cwd,
&[],
)
}
pub fn restore_remote_branch(
cwd: &Path,
remote: &str,
branch: &str,
commit: &str,
) -> Result<Pushed> {
pushed(
&[
"push",
"--porcelain",
"--no-verify",
&format!("--force-with-lease=refs/heads/{branch}:"),
remote,
&format!("{commit}:refs/heads/{branch}"),
],
cwd,
&[],
)
}
fn pushed(args: &[&str], cwd: &Path, env: &[(String, String)]) -> Result<Pushed> {
let output = run_with_env(args, Some(cwd), env)?;
Ok(if output.ok() {
Pushed::Accepted {
output: output.combined(),
}
} else {
Pushed::Refused {
refs: refused_refs(&output.stdout),
diagnostics: PushDiagnostics {
ended: output.ended.clone(),
read_failures: output.read_failures.clone(),
},
output: output.combined(),
}
})
}
fn local_env_vars() -> Result<&'static [String]> {
static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
if let Some(names) = NAMES.get() {
return Ok(names);
}
let listed = checked(&["rev-parse", "--local-env-vars"], None)?.stdout;
let mut names = Vec::new();
for name in listed.split_whitespace() {
let mut characters = name.chars();
let identifier = characters
.next()
.is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
&& characters.all(|rest| rest == '_' || rest.is_ascii_alphanumeric());
if !identifier {
return Err(error::invalid(format!(
"`git rev-parse --local-env-vars` listed {name:?}, which is not an \
environment variable name; check which git is first on PATH"
)));
}
names.push(name.to_owned());
}
Ok(NAMES.get_or_init(|| names))
}
fn inherited_config_count() -> Result<usize> {
let Some(raw) = std::env::var_os("GIT_CONFIG_COUNT") else {
return Ok(0);
};
let count = raw.to_string_lossy();
if count.is_empty() {
return Ok(0);
}
count.parse().map_err(|_| {
error::invalid(format!(
"GIT_CONFIG_COUNT is {count:?}, which is not a count of configuration pairs \
and which git refuses too; unset it or set it to a whole number"
))
})
}
struct HookShims {
dir: PathBuf,
count: usize,
}
impl HookShims {
fn cut(cwd: &Path) -> Result<Option<Self>> {
let Ok(real) = hooks_dir(cwd) else {
return Ok(None);
};
let Ok(entries) = std::fs::read_dir(&real) else {
return Ok(None);
};
let hooks: Vec<_> = entries
.flatten()
.map(|entry| (entry.file_name(), entry.path()))
.filter(|(_, path)| is_executable(path).unwrap_or(false))
.collect();
if hooks.is_empty() {
return Ok(None);
}
let count = inherited_config_count()?;
let unset = local_env_vars()?.join(" ");
let dir = git_owned_path(cwd, &format!("onevcs-hooks-{}", ids::unique()))?;
std::fs::create_dir(&dir).map_err(error::at("create the hook shims in", &dir))?;
let shims = HookShims { dir, count };
for (name, hook) in hooks {
let shim = shims.dir.join(name);
let target = shell_quoted(&git_path(&hook).to_string_lossy());
std::fs::write(
&shim,
format!("#!/bin/sh\nunset {unset}\nexec {target} \"$@\"\n"),
)
.map_err(error::at("write the hook shim", &shim))?;
make_executable(&shim)?;
}
Ok(Some(shims))
}
fn env(&self) -> Vec<(String, String)> {
let count = self.count;
vec![
("GIT_CONFIG_COUNT".to_owned(), (count + 1).to_string()),
(
format!("GIT_CONFIG_KEY_{count}"),
"core.hooksPath".to_owned(),
),
(
format!("GIT_CONFIG_VALUE_{count}"),
git_path(&self.dir).to_string_lossy().into_owned(),
),
]
}
}
impl Drop for HookShims {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn shell_quoted(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
.map_err(error::at("make the hook shim executable at", path))
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<()> {
Ok(())
}
fn refused_refs(porcelain: &str) -> Vec<String> {
porcelain
.lines()
.filter_map(|line| {
let mut fields = line.split('\t');
(fields.next()? == "!")
.then(|| fields.next()?.split_once(':').map(|(_, to)| to.to_owned()))
.flatten()
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectId(String);
impl ObjectId {
pub fn parse(value: &str) -> Option<Self> {
let complete = matches!(value.len(), 40 | 64);
(complete && value.chars().all(|c| c.is_ascii_hexdigit()))
.then(|| ObjectId(value.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteTip {
At(ObjectId),
Absent,
Unknown,
}
pub fn remote_tip(
cwd: &Path,
remote: &str,
branch: &str,
env: &[(String, String)],
) -> Result<RemoteTip> {
let reference = format!("refs/heads/{branch}");
let listing = run_with_env(
&["ls-remote", "--exit-code", remote, &reference],
Some(cwd),
env,
)?;
Ok(match listing.status {
0 => advertised(&listing.stdout, &reference).map_or(RemoteTip::Unknown, RemoteTip::At),
2 => RemoteTip::Absent,
_ => RemoteTip::Unknown,
})
}
pub fn remote_branch_tips(cwd: &Path, remote: &str) -> Result<Option<BTreeMap<String, ObjectId>>> {
let listing = run(&["ls-remote", "--heads", remote], Some(cwd))?;
if !listing.ok() {
return Ok(None);
}
let mut tips = BTreeMap::new();
for line in listing
.stdout
.lines()
.filter(|line| !line.trim().is_empty())
{
let parsed = line.split_once('\t').and_then(|(id, reference)| {
Some((
reference.strip_prefix("refs/heads/")?.to_owned(),
ObjectId::parse(id)?,
))
});
let Some((branch, tip)) = parsed else {
return Ok(None);
};
tips.insert(branch, tip);
}
Ok(Some(tips))
}
fn advertised(listing: &str, reference: &str) -> Option<ObjectId> {
let mut lines = listing.lines().filter(|line| !line.trim().is_empty());
let advertised = lines.next()?;
if lines.next().is_some() {
return None;
}
let (id, named) = advertised.split_once('\t')?;
(named == reference).then(|| ObjectId::parse(id)).flatten()
}
pub fn copy_branch(source: &Path, destination: &Path, branch: &str) -> Result<bool> {
let source = git_path(source).to_string_lossy();
let output = run(
&[
"fetch",
&source,
&format!("refs/heads/{branch}:refs/heads/{branch}"),
],
Some(destination),
)?;
Ok(output.ok())
}
pub fn fetch_into_ref(cwd: &Path, source: &str, branch: &str, into: &str) -> Result<bool> {
let source = git_location(source);
let output = run(
&[
"fetch",
source.as_ref(),
&format!("+refs/heads/{branch}:{into}"),
],
Some(cwd),
)?;
Ok(output.ok())
}
pub fn update_ref(cwd: &Path, reference: &str, commit: &str) -> Result<()> {
checked(&["update-ref", reference, commit], Some(cwd)).map(|_| ())
}
pub fn delete_ref(cwd: &Path, reference: &str) {
let _ = run(&["update-ref", "-d", reference], Some(cwd));
}
pub fn remotes(cwd: &Path) -> Result<Vec<String>> {
Ok(checked(&["remote"], Some(cwd))?
.stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect())
}
pub fn import_branch(cwd: &Path, source: &Path, branch: &str) -> Result<bool> {
let source = git_path(source).to_string_lossy();
let output = run(
&[
"fetch",
&source,
&format!("+refs/heads/{branch}:refs/heads/{branch}"),
],
Some(cwd),
)?;
Ok(output.ok())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalTip {
At(ObjectId),
Absent,
Unreadable(String),
}
pub fn local_tip(cwd: &Path, branch: &str) -> LocalTip {
if !cwd.exists() {
return LocalTip::Absent;
}
let reference = format!("refs/heads/{branch}");
if let Some(tip) = crate::native_refs::raw_tip(cwd, &reference) {
return match tip.and_then(|tip| ObjectId::parse(&tip)) {
Some(tip) => LocalTip::At(tip),
None => LocalTip::Absent,
};
}
match run(
&["for-each-ref", "--format=%(objectname)", &reference],
Some(cwd),
) {
Ok(output) if output.ok() => {
let listed = output.trimmed();
if listed.is_empty() {
return LocalTip::Absent;
}
match ObjectId::parse(&listed) {
Some(tip) => LocalTip::At(tip),
None => LocalTip::Unreadable(format!(
"git listed {reference} as {listed:?}, which is not one commit"
)),
}
}
Ok(output) => LocalTip::Unreadable(output.diagnostic()),
Err(failure) => LocalTip::Unreadable(failure.to_string()),
}
}
pub fn heads(cwd: &Path) -> Result<Vec<(String, String)>> {
if let Some(heads) = crate::native_refs::heads(cwd) {
return Ok(heads);
}
Ok(checked(
&[
"for-each-ref",
"--format=%(refname:short)%00%(objectname)",
"refs/heads",
],
Some(cwd),
)?
.stdout
.lines()
.filter_map(|line| line.split_once('\0'))
.map(|(name, tip)| (name.to_owned(), tip.to_owned()))
.collect())
}
pub fn remote_heads(cwd: &Path, remote: &str) -> Result<BTreeMap<String, String>> {
if let Some(heads) = crate::native_refs::remote_heads(cwd, remote) {
return Ok(heads);
}
let prefix = format!("refs/remotes/{remote}/");
Ok(checked(
&[
"for-each-ref",
"--format=%(refname)%00%(objectname)",
&prefix,
],
Some(cwd),
)?
.stdout
.lines()
.filter_map(|line| line.split_once('\0'))
.filter_map(|(name, tip)| Some((name.strip_prefix(&prefix)?.to_owned(), tip.to_owned())))
.filter(|(name, _)| name != "HEAD")
.collect())
}
pub fn worktrees(cwd: &Path) -> Result<Vec<(PathBuf, Option<String>)>> {
if let Some(worktrees) = crate::native_refs::worktrees(cwd) {
return Ok(worktrees);
}
let listing = checked(&["worktree", "list", "--porcelain", "-z"], Some(cwd))?;
let mut found = Vec::new();
let mut path: Option<PathBuf> = None;
let mut branch: Option<String> = None;
for field in listing.stdout.split('\0') {
if field.is_empty() {
if let Some(done) = path.take() {
found.push((done, branch.take()));
}
continue;
}
if let Some(at) = field.strip_prefix("worktree ") {
path = Some(PathBuf::from(at));
} else if let Some(named) = field.strip_prefix("branch refs/heads/") {
branch = Some(named.to_owned());
}
}
if let Some(done) = path.take() {
found.push((done, branch.take()));
}
Ok(found)
}
pub fn content_free_tail<'a>(
cwd: impl Into<Asked<'a>>,
commit: &str,
stop: &str,
) -> Result<(Vec<String>, String)> {
let listed = checked_in(
cwd.into(),
&[
"log",
"--first-parent",
&format!("-n{}", CONTENT_FREE_WALK + 1),
"--format=%H%x00%T",
commit,
"--",
],
)?;
let chain: Vec<(&str, &str)> = listed
.stdout
.lines()
.filter_map(|line| line.split_once('\0'))
.collect();
let mut skipped = Vec::new();
for pair in chain.windows(2) {
let ((at, tree), (_, parent_tree)) = (pair[0], pair[1]);
if at == stop || tree != parent_tree {
return Ok((skipped, at.to_owned()));
}
skipped.push(at.to_owned());
}
let at = chain
.get(skipped.len())
.map(|(at, _)| (*at).to_owned())
.unwrap_or_else(|| commit.to_owned());
Ok((skipped, at))
}
const CONTENT_FREE_WALK: usize = 64;
pub fn carries_content_of<'a>(cwd: impl Into<Asked<'a>>, head: &str, tip: &str) -> Result<bool> {
let cwd = cwd.into();
if !has_commit(cwd, &Sha(head.to_owned())) || !has_commit(cwd, &Sha(tip.to_owned())) {
return Ok(false);
}
let met = merge_base(cwd, head, tip)?.unwrap_or_default();
let (_, content_tip) = content_free_tail(cwd, tip, &met)?;
known_to_reach(cwd, &content_tip, head)
}
pub fn worktree_heads(cwd: &Path) -> Result<Vec<(PathBuf, Option<String>)>> {
match worktree_heads_on_disk(cwd) {
Some(found) => Ok(found),
None => worktrees(cwd),
}
}
fn worktree_heads_on_disk(cwd: &Path) -> Option<Vec<(PathBuf, Option<String>)>> {
let dot_git = cwd.join(".git");
if !dot_git.is_dir() {
return None;
}
let branch_of = |head: &Path| -> Option<Option<String>> {
let text = std::fs::read_to_string(head).ok()?;
let text = text.trim();
if let Some(named) = text.strip_prefix("ref: ") {
return Some(named.strip_prefix("refs/heads/").map(str::to_owned));
}
ObjectId::parse(text).map(|_| None)
};
let mut found = vec![(cwd.to_path_buf(), branch_of(&dot_git.join("HEAD"))?)];
let linked = dot_git.join("worktrees");
let entries = match std::fs::read_dir(&linked) {
Ok(entries) => entries,
Err(absent) if absent.kind() == std::io::ErrorKind::NotFound => return Some(found),
Err(_) => return None,
};
for entry in entries {
let admin = entry.ok()?.path();
let gitdir = std::fs::read_to_string(admin.join("gitdir")).ok()?;
let worktree = Path::new(gitdir.trim()).parent()?.to_path_buf();
found.push((worktree, branch_of(&admin.join("HEAD"))?));
}
Some(found)
}
pub fn commit_message<'a>(cwd: impl Into<Asked<'a>>, commit: &str) -> Result<Option<String>> {
let cwd = cwd.into();
if !has_commit(cwd, &Sha(commit.to_owned())) {
return Ok(None);
}
Ok(Some(
checked_in(cwd, &["log", "-1", "--format=%B", commit, "--"])?
.stdout
.trim_end()
.to_owned(),
))
}
pub fn first_commit_mentioning(cwd: &Path, rev: &str, needle: &str) -> Option<String> {
let immutable = crate::native_refs::tip(cwd, rev).flatten();
let rev = immutable.as_deref().unwrap_or(rev);
run(
&[
"log",
"-n",
"1",
"--format=%H",
"--fixed-strings",
&format!("--grep={needle}"),
rev,
"--",
],
Some(cwd),
)
.ok()
.filter(Output::ok)
.map(|out| out.trimmed())
.filter(|found| !found.is_empty())
}
pub fn commits_in<'a>(cwd: impl Into<Asked<'a>>, range: &str) -> Result<Vec<String>> {
Ok(
checked_in(cwd.into(), &["rev-list", "--reverse", range, "--"])?
.stdout
.lines()
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect(),
)
}
pub fn changes_no_content<'a>(cwd: impl Into<Asked<'a>>, commit: &str) -> Result<bool> {
let cwd = cwd.into();
let parent = run_in(
cwd,
&["rev-parse", "--verify", &format!("{commit}^1^{{commit}}")],
)?;
if !parent.ok() {
return Ok(false);
}
let trees = checked_in(
cwd,
&[
"rev-parse",
&format!("{commit}^{{tree}}"),
&format!("{}^{{tree}}", parent.trimmed()),
],
)?;
let mut lines = trees.stdout.lines();
Ok(lines.next() == lines.next())
}
pub fn changed_paths<'a>(cwd: impl Into<Asked<'a>>, from: &str, to: &str) -> Result<Vec<String>> {
let cwd = cwd.into();
let listed = checked_in(
cwd,
&["diff", "--name-only", "--no-renames", "-z", from, to, "--"],
)?;
let paths: Vec<String> = listed
.stdout
.split('\0')
.filter(|path| !path.is_empty())
.map(str::to_owned)
.collect();
if !paths.is_empty() && listed.read_failures.is_empty() {
return Ok(paths);
}
let counted = counted_files(cwd, from, to)?;
if paths.len() != counted {
return Err(Error::Invalid {
reason: format!(
"git diff {from} {to} listed {} path(s) and counted {counted}, so which paths it \
changed could not be read whole",
paths.len()
),
});
}
Ok(paths)
}
pub fn differing_among<'a>(
cwd: impl Into<Asked<'a>>,
first: &str,
second: &str,
paths: &[String],
) -> Result<Vec<String>> {
if paths.is_empty() {
return Ok(Vec::new());
}
let mut args = vec![
"diff".to_owned(),
"--name-only".to_owned(),
"--no-renames".to_owned(),
"-z".to_owned(),
first.to_owned(),
second.to_owned(),
"--".to_owned(),
];
args.extend(paths.iter().map(|path| format!(":(literal){path}")));
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let listed = checked_in(cwd.into(), &args)?;
let mut differing: Vec<String> = listed
.stdout
.split('\0')
.filter(|path| !path.is_empty())
.map(str::to_owned)
.collect();
differing.sort();
differing.dedup();
Ok(differing)
}
pub fn delete_branch_at(cwd: &Path, branch: &str, expected: &str) -> Result<()> {
checked(
&[
"update-ref",
"-d",
&format!("refs/heads/{branch}"),
expected,
],
Some(cwd),
)
.map(|_| ())
}
pub fn restore_branch(cwd: &Path, branch: &str, commit: &str) -> Result<()> {
checked(
&["update-ref", &format!("refs/heads/{branch}"), commit, ""],
Some(cwd),
)
.map(|_| ())
}
pub fn delete_remote_branch(
cwd: &Path,
remote: &str,
branch: &str,
expected: &str,
) -> Result<Pushed> {
pushed(
&[
"push",
"--porcelain",
"--no-verify",
&format!("--force-with-lease=refs/heads/{branch}:{expected}"),
"--delete",
remote,
&format!("refs/heads/{branch}"),
],
cwd,
&[],
)
}
pub fn fetch_commit(cwd: &Path, remote: &str, commit: &str) -> Result<bool> {
let _turn = lock::exclusive_at(&common_dir(cwd)?.join(FETCH_LOCK))?;
let fetched = run(
&["fetch", "--no-tags", "--refmap=", remote, commit],
Some(cwd),
)?
.ok();
Ok(fetched && has_commit(cwd, &Sha(commit.to_owned())))
}
pub fn fetch_objects_of(cwd: &Path, remote: &str, branch: &str) -> Result<bool> {
let _turn = lock::exclusive_at(&common_dir(cwd)?.join(FETCH_LOCK))?;
Ok(run(
&[
"fetch",
"--no-tags",
"--refmap=",
remote,
&format!("refs/heads/{branch}"),
],
Some(cwd),
)?
.ok())
}
#[cfg(test)]
mod ref_name_tests {
use super::*;
const NAMES: &[&str] = &[
"main",
"HEAD",
"feature/x",
"onevcs/s-9fa99cfb80da",
"worktree-agent-7",
"preserved/by-trailer",
"release/v1.2.3",
"a_b-c.d/e",
"a.lock",
"locked/a.lock",
".hidden",
"hidden/.x",
"a..b",
"ends.",
"with space",
"with~tilde",
"with^caret",
"with:colon",
"question?",
"star*",
"brack[et",
"at@{brace",
"@",
"back\\slash",
"double//slash",
"trailing/",
"/leading",
"unicode/ünïcøde",
"标点/分支.v1",
"punct/one!two#three$four%five&six(seven)+eight=nine;ten,eleven",
"unicode/space\u{a0}inside",
"dots/interior.part/last",
"control\u{1}char",
];
fn git_accepts(branch: &str) -> bool {
run(&["check-ref-format", &format!("refs/heads/{branch}")], None)
.map(|out| out.ok())
.unwrap_or(false)
}
#[test]
fn the_fast_path_accepts_only_names_git_accepts() {
for name in NAMES {
if plainly_a_ref_name(name) {
assert!(
git_accepts(name),
"{name:?} is accepted without asking git, and git refuses it"
);
}
}
for ordinary in [
"main",
"feature/x",
"onevcs/s-9fa99cfb80da",
"worktree-agent-7",
"unicode/ünïcøde",
"标点/分支.v1",
"punct/one!two#three$four%five&six(seven)+eight=nine;ten,eleven",
"dots/interior.part/last",
] {
assert!(
plainly_a_ref_name(ordinary),
"{ordinary:?} is the ordinary shape and must be decided in process"
);
}
}
#[test]
fn a_name_is_valid_here_exactly_when_git_says_so() {
for name in NAMES {
let expected = git_accepts(name) && !name.starts_with('-');
assert_eq!(
is_valid_branch_name(name),
expected,
"{name:?}: this build and git disagree about whether it is a ref name"
);
}
}
}
#[cfg(all(test, unix))]
mod fetch_turns {
use std::os::unix::fs::PermissionsExt;
use super::*;
fn scratch() -> (tempfile::TempDir, PathBuf) {
let directory = tempfile::tempdir().expect("a scratch directory");
let root = directory
.path()
.canonicalize()
.expect("a canonical scratch root");
std::env::set_var(lock::TIMEOUT_ENV, "10");
(directory, root)
}
fn git(cwd: &Path, args: &[&str]) {
checked(args, Some(cwd)).expect("the fixture's git succeeds");
}
fn origin(root: &Path) -> (PathBuf, PathBuf) {
let origin = root.join("origin.git");
git(
root,
&[
"init",
"-q",
"--bare",
"-b",
"main",
&origin.to_string_lossy(),
],
);
let writer = root.join("writer");
git(
root,
&[
"clone",
"-q",
&origin.to_string_lossy(),
&writer.to_string_lossy(),
],
);
advance(&writer);
(origin, writer)
}
fn advance(writer: &Path) {
git(
writer,
&[
"-c",
"user.name=Fetch",
"-c",
"user.email=fetch@example.invalid",
"commit",
"-q",
"--allow-empty",
"-m",
"move main on",
],
);
git(writer, &["push", "-q", "origin", "HEAD:main"]);
}
fn checkout(root: &Path, origin: &Path, name: &str) -> PathBuf {
let checkout = root.join(name);
git(
root,
&[
"clone",
"-q",
&origin.to_string_lossy(),
&checkout.to_string_lossy(),
],
);
checkout
}
fn gate(root: &Path, checkout: &Path) -> PathBuf {
let gate = root.join(format!(
"gate-{}",
checkout.file_name().unwrap_or_default().to_string_lossy()
));
let hooks = gate.join("hooks");
std::fs::create_dir_all(&hooks).expect("a hooks directory");
let hook = hooks.join("reference-transaction");
std::fs::write(
&hook,
format!(
"#!/bin/sh\n\
[ \"$1\" = prepared ] || exit 0\n\
grep -q ' refs/remotes/origin/main$' || exit 0\n\
[ -e {gate}/refuse ] && exit 1\n\
mkdir {gate}/held 2>/dev/null || exit 0\n\
echo \"$PPID\" > {gate}/held/git.tmp\n\
mv {gate}/held/git.tmp {gate}/held/git\n\
until [ -e {gate}/release ]; do sleep 0.02; done\n",
gate = gate.display()
),
)
.expect("the hook is written");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))
.expect("the hook is executable");
git(
checkout,
&["config", "core.hooksPath", &hooks.to_string_lossy()],
);
gate
}
fn held_fetch(checkout: &Path, gate: &Path) -> std::thread::JoinHandle<Result<()>> {
let at = checkout.to_path_buf();
let fetching = std::thread::spawn(move || fetch(&at, "origin"));
let deadline = Instant::now() + Duration::from_secs(30);
while !gate.join("held/git").is_file() {
assert!(
!fetching.is_finished(),
"the fetch ended before its hook held it"
);
assert!(Instant::now() < deadline, "the fetch was never held");
std::thread::sleep(Duration::from_millis(20));
}
fetching
}
fn tracked_main(checkout: &Path) -> String {
checked(&["rev-parse", "refs/remotes/origin/main"], Some(checkout))
.expect("the checkout tracks origin's main")
.trimmed()
}
fn origin_main(origin: &Path) -> String {
checked(&["rev-parse", "main"], Some(origin))
.expect("origin has a main")
.trimmed()
}
#[test]
fn a_fetch_git_failed_hands_the_checkout_to_the_next_fetch() {
let (_directory, root) = scratch();
let (origin, writer) = origin(&root);
let checkout = checkout(&root, &origin, "checkout");
let gate = gate(&root, &checkout);
advance(&writer);
std::fs::write(gate.join("refuse"), "").expect("the hook refuses");
let refused = fetch(&checkout, "origin").expect_err("the hook refused the update");
assert!(
refused
.to_string()
.contains("git fetch origin --prune failed"),
"git's own failure is what is reported: {refused}"
);
std::fs::remove_file(gate.join("refuse")).expect("the hook stops refusing");
std::fs::write(gate.join("release"), "").expect("nothing is held");
fetch(&checkout, "origin").expect("the next fetch takes its turn");
assert_eq!(tracked_main(&checkout), origin_main(&origin));
}
#[test]
fn a_fetch_whose_git_was_killed_hands_the_checkout_to_the_next_fetch() {
let (_directory, root) = scratch();
let (origin, writer) = origin(&root);
let checkout = checkout(&root, &origin, "checkout");
let gate = gate(&root, &checkout);
advance(&writer);
let killed = held_fetch(&checkout, &gate);
let pid: libc::pid_t = std::fs::read_to_string(gate.join("held/git"))
.expect("the held git's pid")
.trim()
.parse()
.expect("a pid");
assert_eq!(
unsafe { libc::kill(pid, libc::SIGTERM) },
0,
"the git is signalled"
);
let ended = killed.join().expect("the fetching thread returns");
assert!(ended.is_err(), "a killed fetch is a failed one");
std::fs::write(gate.join("release"), "").expect("the orphaned hook ends");
fetch(&checkout, "origin").expect("the next fetch takes its turn");
assert_eq!(tracked_main(&checkout), origin_main(&origin));
}
#[test]
fn fetches_into_two_checkouts_do_not_wait_on_each_other() {
let (_directory, root) = scratch();
let (origin, writer) = origin(&root);
let held = checkout(&root, &origin, "held");
let free = checkout(&root, &origin, "free");
let gate = gate(&root, &held);
advance(&writer);
let holding = held_fetch(&held, &gate);
fetch(&free, "origin").expect("a fetch into another checkout runs meanwhile");
assert_eq!(tracked_main(&free), origin_main(&origin));
assert!(
!holding.is_finished(),
"the other checkout's fetch is still held while this one completed"
);
std::fs::write(gate.join("release"), "").expect("the held fetch is let go");
holding
.join()
.expect("the fetching thread returns")
.expect("the held fetch completes");
assert_eq!(tracked_main(&held), origin_main(&origin));
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::*;
fn configure_repository(repo: &Path) {
checked(&["init", "-q", "-b", "main"], Some(repo)).expect("git initializes");
configure_identity(repo);
checked(&["commit", "--allow-empty", "-q", "-m", "seed"], Some(repo))
.expect("a seed commit");
}
fn configure_identity(repo: &Path) {
checked(&["config", "user.name", "Journey"], Some(repo)).expect("a user name");
checked(
&["config", "user.email", "journey@example.invalid"],
Some(repo),
)
.expect("a user email");
}
#[test]
fn canonical_windows_paths_cross_every_git_path_boundary() {
let directory = tempfile::tempdir().expect("a scratch directory");
let root = std::fs::canonicalize(directory.path()).expect("a canonical Windows path");
assert!(
root.to_string_lossy().starts_with(r"\\?\"),
"Windows canonicalize must exercise the verbatim-path defect"
);
let source = root.join("source");
std::fs::create_dir(&source).expect("a source directory");
configure_repository(&source);
let hooks = root.join("hooks");
std::fs::create_dir(&hooks).expect("a hooks directory");
checked(
&["config", "core.hooksPath", &hooks.to_string_lossy()],
Some(&source),
)
.expect("a canonical hooks path is configured");
let clone = root.join("clone");
clone_sharing(&source, &clone, &source.to_string_lossy(), "main")
.expect("canonical source and clone paths reach git");
configure_identity(&clone);
fetch(&clone, "origin").expect("a canonical local origin reaches git");
assert_eq!(
hooks_dir(&clone).expect("the carried hooks path"),
git_path(&hooks),
"the clone carries the simplified hooks path"
);
let worktree = root.join("worktree");
worktree_add(&clone, &worktree, "feature/windows-path", "main")
.expect("a canonical worktree path reaches git");
commit_empty(&worktree, "work").expect("a canonical working directory reaches git");
let destination = root.join("destination");
std::fs::create_dir(&destination).expect("a destination directory");
configure_repository(&destination);
assert!(
copy_branch(&clone, &destination, "feature/windows-path")
.expect("a canonical local-fetch source reaches git"),
"the branch is copied"
);
assert!(
import_branch(&destination, &clone, "feature/windows-path")
.expect("a canonical import source reaches git"),
"the branch is imported"
);
worktree_remove(&clone, &worktree).expect("a canonical removal path reaches git");
assert!(!worktree.exists(), "git removed the worktree");
let existing = root.join("existing-worktree");
worktree_add_existing(&clone, &existing, "feature/windows-path")
.expect("a canonical existing-worktree path reaches git");
worktree_remove(&clone, &existing).expect("the existing worktree is removed");
let detached = root.join("detached-worktree");
worktree_add_detached(&clone, &detached, "main")
.expect("a canonical detached-worktree path reaches git");
worktree_remove(&clone, &detached).expect("the detached worktree is removed");
}
}
#[cfg(all(test, unix))]
mod collecting {
use std::io::Read;
use std::os::fd::{AsRawFd, RawFd};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use super::PipeCapture;
const HELD: Duration = Duration::from_millis(300);
const PADDING: usize = 20_000;
const LAST_WORD: &str = "released 1.2.3\n";
struct Held<R> {
pipe: R,
empty_reads: Arc<AtomicUsize>,
}
impl<R: Read> Read for Held<R> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
let answer = self.pipe.read(buffer);
if matches!(&answer, Err(error) if error.kind() == std::io::ErrorKind::WouldBlock) {
self.empty_reads.fetch_add(1, Ordering::Release);
std::thread::sleep(HELD);
}
answer
}
}
impl<R: AsRawFd> AsRawFd for Held<R> {
fn as_raw_fd(&self) -> RawFd {
self.pipe.as_raw_fd()
}
}
#[test]
fn a_reader_held_past_the_exit_still_returns_everything_the_command_wrote() {
let expected = format!("{}{LAST_WORD}", "0".repeat(PADDING));
let mut child = Command::new("sh")
.arg("-c")
.arg(format!(
"read go; printf '%0{PADDING}d' 0; printf '{LAST_WORD}'"
))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("a shell is on PATH");
let empty_reads = Arc::new(AtomicUsize::new(0));
let (ended, _endings) = std::sync::mpsc::channel();
let reader = PipeCapture::start(
Held {
pipe: child.stdout.take().expect("stdout was piped"),
empty_reads: Arc::clone(&empty_reads),
},
ended,
);
while empty_reads.load(Ordering::Acquire) == 0 {
std::thread::yield_now();
}
let mut go = child.stdin.take().expect("stdin was piped");
std::io::Write::write_all(&mut go, b"go\n").expect("the command is released");
drop(go);
let status = loop {
match child.try_wait().expect("the command can be asked") {
Some(status) => break status,
None => std::thread::yield_now(),
}
};
assert!(status.success(), "the command itself succeeded");
let collected = reader.finish();
assert!(
collected.failure.is_none(),
"no read failed: {:?}",
collected.failure
);
let collected = collected.bytes;
assert_eq!(
collected.len(),
expected.len(),
"a reader that met the pipe empty and was held past the command's exit must still \
return every byte the command wrote: {recovered} of {whole} bytes, ending {tail:?}",
recovered = collected.len(),
whole = expected.len(),
tail = String::from_utf8_lossy(&collected[collected.len().saturating_sub(24)..]),
);
assert!(
collected == expected.as_bytes(),
"and they are the command's own bytes, in the order it wrote them"
);
}
}