use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crate::error::{self, Error, Result};
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_SECONDS: f64 = 30.0;
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 hooks = runs_repository_hooks(args);
let bound = timeout_seconds(hooks)?;
let started = Instant::now();
let mut command = Command::new("git");
command
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(directory) = cwd {
command.current_dir(directory);
}
for (key, value) in env {
command.env(key, value);
}
detach_process_group(&mut command);
let mut child = command.spawn().map_err(|e| {
error::invalid(format!(
"cannot run git: {e} (is git installed and on PATH?)"
))
})?;
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 = String::new();
let _ = stdout.read_to_string(&mut buffer);
let _ = out_sender.send(());
buffer
});
let err_reader = std::thread::spawn(move || {
let mut buffer = String::new();
let _ = stderr.read_to_string(&mut buffer);
let _ = sender.send(());
buffer
});
let deadline = Duration::from_secs_f64(bound);
let drained = wait_for_both(&receiver, deadline);
if !drained {
terminate_group(&child);
if !wait_for_both(&receiver, Duration::from_secs_f64(DRAIN_SECONDS)) {
let _ = child.kill();
}
let _ = child.wait();
let _ = out_reader.join();
let _ = err_reader.join();
let elapsed = started.elapsed().as_secs_f64();
let knob = if hooks { HOOK_TIMEOUT_ENV } else { TIMEOUT_ENV };
return Err(Error::Invalid {
reason: format!(
"git {} timed out after {elapsed:.3}s (bound {bound}s; raise it with {knob})",
args.join(" ")
),
});
}
let status = child
.wait()
.map_err(|e| error::invalid(format!("cannot collect git {}: {e}", args.join(" "))))?;
let stdout = out_reader.join().unwrap_or_default();
let stderr = err_reader.join().unwrap_or_default();
Ok(Output {
status: status.code().unwrap_or(128),
stdout,
stderr,
})
}
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 timeout_seconds(hooks: bool) -> Result<f64> {
let (name, default) = if hooks {
(HOOK_TIMEOUT_ENV, DEFAULT_HOOK_TIMEOUT_SECONDS)
} else {
(TIMEOUT_ENV, DEFAULT_TIMEOUT_SECONDS)
};
let Some(raw) = std::env::var_os(name) else {
return Ok(default);
};
let raw = raw.to_string_lossy().into_owned();
let value: f64 = raw.trim().parse().map_err(|_| Error::Invalid {
reason: format!("{name} must be a number of seconds, not {raw:?}"),
})?;
if !value.is_finite() || value <= 0.0 {
return Err(Error::Invalid {
reason: format!("{name} must be a finite number of seconds above zero, not {raw:?}"),
});
}
Ok(value)
}
pub fn check_bounds() -> Result<()> {
timeout_seconds(false)?;
timeout_seconds(true)?;
Ok(())
}
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<()>, bound: Duration) -> bool {
let deadline = Instant::now() + bound;
for _ in 0..2 {
let remaining = deadline.saturating_duration_since(Instant::now());
if receiver.recv_timeout(remaining).is_err() {
return false;
}
}
true
}
#[cfg(unix)]
fn detach_process_group(command: &mut Command) {
use std::os::unix::process::CommandExt;
command.process_group(0);
}
#[cfg(not(unix))]
fn detach_process_group(_command: &mut Command) {}
#[cfg(unix)]
fn terminate_group(child: &Child) {
unsafe {
libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL);
}
}
#[cfg(not(unix))]
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<()> {
checked(
&[
"clone",
"--shared",
"--no-checkout",
&source.to_string_lossy(),
&dest.to_string_lossy(),
],
None,
)?;
checked(&["remote", "set-url", "origin", origin], Some(dest))?;
checked(
&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
&format!("refs/remotes/origin/{base}"),
],
Some(dest),
)?;
carry_hooks(source, 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", &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> {
let value = checked(&["rev-parse", "--git-path", "hooks"], Some(cwd))?.trimmed();
let path = PathBuf::from(&value);
Ok(if path.is_absolute() {
path
} else {
cwd.join(path)
})
}
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 current_branch(cwd: &Path) -> Result<String> {
Ok(checked(&["rev-parse", "--abbrev-ref", "HEAD"], Some(cwd))?.trimmed())
}
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)? {
let counted = run(
&["rev-list", "--count", &branch, "--not", "--remotes=origin"],
Some(cwd),
)?;
if counted.ok() && counted.trimmed().parse::<u64>().unwrap_or(0) > 0 {
unpublished.push(branch);
}
}
Ok(unpublished)
}
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()),
}),
}
}
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 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<()> {
checked(
&[
"worktree",
"add",
"-b",
branch,
&path.to_string_lossy(),
base,
],
Some(cwd),
)
.map(|_| ())
}
pub fn worktree_add_existing(cwd: &Path, path: &Path, branch: &str) -> Result<()> {
checked(
&["worktree", "add", &path.to_string_lossy(), branch],
Some(cwd),
)
.map(|_| ())
}
pub fn worktree_add_detached(cwd: &Path, path: &Path, reference: &str) -> Result<()> {
checked(
&[
"worktree",
"add",
"--detach",
&path.to_string_lossy(),
reference,
],
Some(cwd),
)
.map(|_| ())
}
pub fn worktree_remove(cwd: &Path, path: &Path) -> Result<()> {
run(
&["worktree", "remove", "--force", &path.to_string_lossy()],
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<bool> {
let merged = run(&["merge", "--no-edit", "-m", message, reference], Some(cwd))?;
if merged.ok() {
return Ok(true);
}
let unmerged = run(&["diff", "--name-only", "--diff-filter=U"], Some(cwd))?;
if !unmerged.ok() || unmerged.trimmed().is_empty() {
return Err(Error::Invalid {
reason: format!("git merge {reference} failed: {}", merged.diagnostic()),
});
}
run(&["merge", "--abort"], Some(cwd))?;
Ok(false)
}
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(|_| ())
}
pub fn push(
cwd: &Path,
branch: &str,
remote: &str,
env: &[(String, String)],
) -> Result<std::result::Result<String, String>> {
let output = run_with_env(&["push", remote, branch], Some(cwd), env)?;
Ok(if output.ok() {
Ok(output.combined())
} else {
Err(output.combined())
})
}
pub fn copy_branch(source: &Path, destination: &Path, branch: &str) -> Result<bool> {
let output = run(
&[
"fetch",
&source.to_string_lossy(),
&format!("refs/heads/{branch}:refs/heads/{branch}"),
],
Some(destination),
)?;
Ok(output.ok())
}
pub fn import_branch(cwd: &Path, source: &Path, branch: &str) -> Result<bool> {
let output = run(
&[
"fetch",
&source.to_string_lossy(),
&format!("+refs/heads/{branch}:refs/heads/{branch}"),
],
Some(cwd),
)?;
Ok(output.ok())
}