use std::borrow::Cow;
use std::io::Read;
use std::num::NonZeroI32;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crate::error::{self, Error, Result};
use crate::host::Sha;
use crate::ids;
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 DRAIN: Duration = Duration::from_secs(30);
const EXIT_POLL: Duration = Duration::from_millis(10);
#[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 stdout: String,
pub stderr: 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> {
let mut command = Command::new("git");
command.args(args);
let ran = bounded(
command,
cwd,
env,
if runs_repository_hooks(args) {
Bound::Hooks
} else {
Bound::Ordinary
},
&format!("git {}", args.join(" ")),
|e| unstarted(&e, args, cwd),
)?;
Ok(Output {
status: ran.status,
stdout: text(ran.stdout),
stderr: text(ran.stderr),
})
}
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 mut stdout = child.stdout.take().expect("stdout was piped");
let mut stderr = child.stderr.take().expect("stderr was piped");
let (sender, receiver) = mpsc::channel();
let out_sender = sender.clone();
let out_reader = std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = stdout.read_to_end(&mut buffer);
let _ = out_sender.send(());
buffer
});
let err_reader = std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = stderr.read_to_end(&mut buffer);
let _ = sender.send(());
buffer
});
let drained = wait_for_both(&receiver, started, bound);
let exited = if drained {
wait_for_exit(&mut child, started, bound)
} else {
None
};
let Some(collected) = exited else {
terminate_group(&child);
if drained || !wait_for_both(&receiver, Instant::now(), DRAIN) {
let _ = child.kill();
}
let _ = child.wait();
let _ = out_reader.join();
let _ = err_reader.join();
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 status = collected.map_err(|e| error::invalid(format!("cannot collect {label}: {e}")))?;
Ok(Ran {
status: status.code().unwrap_or(128),
stdout: out_reader.join().unwrap_or_default(),
stderr: err_reader.join().unwrap_or_default(),
})
}
struct Ran {
status: i32,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
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()
),
})
}
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(cwd: &Path, sha: &Sha) -> bool {
run(
&["cat-file", "-e", &format!("{}^{{commit}}", sha.0)],
Some(cwd),
)
.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> {
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_both(receiver: &mpsc::Receiver<()>, started: Instant, bound: Duration) -> bool {
for _ in 0..2 {
let remaining = bound.saturating_sub(started.elapsed());
if receiver.recv_timeout(remaining).is_err() {
return false;
}
}
true
}
fn wait_for_exit(
child: &mut Child,
started: Instant,
bound: Duration,
) -> 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) => std::thread::sleep(EXIT_POLL),
}
}
}
#[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 {
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 = checked(&["remote", "get-url", remote], Some(cwd))?.trimmed();
if value.is_empty() || value.contains(['\n', '\r']) {
return Err(Error::Invalid {
reason: format!("git remote {remote:?} returned an unusable URL"),
});
}
Ok(value)
}
pub fn has_remote(cwd: &Path, remote: &str) -> bool {
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<()> {
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),
)?;
carry_remote_refs(source, dest, base)?;
carry_hooks(source, dest)?;
Ok(())
}
pub fn carry_remote_refs(source: &Path, dest: &Path, base: &str) -> Result<()> {
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),
)?;
Ok(())
}
fn carry_hooks(source: &Path, dest: &Path) -> Result<()> {
let configured = run(&["config", "--get", "core.hooksPath"], Some(source))?.trimmed();
let hooks = if configured.is_empty() {
let tracked = source.join(".githooks");
if !tracked.is_dir() {
return Ok(());
}
tracked
} else {
let path = PathBuf::from(&configured);
if path.is_absolute() {
path
} else {
source.join(path)
}
};
checked(
&[
"config",
"core.hooksPath",
&git_path(&hooks).to_string_lossy(),
],
Some(dest),
)
.map(|_| ())
}
pub fn retain_objects_for_borrowers(cwd: &Path) -> Result<()> {
checked(&["config", "gc.auto", "0"], Some(cwd))?;
checked(&["config", "gc.pruneExpire", "never"], Some(cwd))?;
Ok(())
}
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));
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 fn fetch(cwd: &Path, remote: &str) -> Result<()> {
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 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 {
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 committer_date(cwd: &Path, commit: &str) -> Option<String> {
run(
&[
"show",
"-s",
"--format=%cI",
&format!("{commit}^{{commit}}"),
],
Some(cwd),
)
.ok()
.filter(Output::ok)
.map(|output| output.trimmed())
.filter(|date| !date.is_empty())
}
pub fn branches(cwd: &Path) -> Result<Vec<String>> {
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 unpublished_branches(cwd: &Path) -> Result<Vec<String>> {
let mut unpublished = Vec::new();
for branch in branches(cwd)? {
if unpublished_ahead(cwd, &branch, &[])? > 0 {
unpublished.push(branch);
}
}
Ok(unpublished)
}
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 mut args = vec![
"rev-list",
"--count",
reference,
"--not",
"--remotes=origin",
];
args.extend_from_slice(&held);
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 fn is_valid_branch_name(branch: &str) -> bool {
if branch.is_empty() || branch.starts_with('-') {
return false;
}
run(&["check-ref-format", &format!("refs/heads/{branch}")], None)
.map(|out| out.ok())
.unwrap_or(false)
}
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)
}
#[derive(Debug, Clone)]
pub struct CommitMessage {
pub sha: String,
pub message: String,
}
pub fn log_messages(cwd: &Path, base: &str, branch: &str) -> Result<Vec<CommitMessage>> {
let output = checked(
&[
"log",
"--reverse",
"--format=%H%x00%B%x00%x1e",
&format!("{base}..{branch}"),
],
Some(cwd),
)?;
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 trees_differ(cwd: &Path, base: &str, branch: &str) -> Result<bool> {
let output = run(&["diff", "--quiet", base, branch], Some(cwd))?;
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(cwd: &Path, from: &str, to: &str) -> Result<Lines> {
let listed = checked(&["diff", "--numstat", "--no-renames", from, to], Some(cwd))?;
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 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(cwd: &Path, ancestor: &str, descendant: &str) -> Result<bool> {
let output = run(
&["merge-base", "--is-ancestor", ancestor, descendant],
Some(cwd),
)?;
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(cwd: &Path, ancestor: &str, descendant: &str) -> Result<bool> {
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(cwd: &Path, first: &str, second: &str) -> Result<Option<String>> {
let output = run(&["merge-base", first, second], Some(cwd))?;
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: &Path, from: &str, to: &str) -> Result<usize> {
let summary = checked(
&["diff", "--shortstat", "--no-renames", from, to],
Some(cwd),
)?
.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(cwd: &Path, base: &str, fork: &str, commit: &str) -> Result<bool> {
let listed = checked(
&["diff", "--name-only", "--no-renames", "-z", fork, commit],
Some(cwd),
)?;
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(&args, Some(cwd))?;
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()
),
}),
}
}
#[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> {
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 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_into_branch(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()),
});
};
run(&["merge", "--abort"], Some(cwd))?;
Ok(Integrated::Conflicted(conflict))
}
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 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>,
},
}
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 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);
}
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),
output: output.combined(),
}
})
}
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,
})
}
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())
}
#[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");
}
}