use crate::forwarding::git_command;
use crate::repo_read_hook::try_request;
use crate::child_class::{ChildClass, class_of};
use crate::child_watch::{StepBound, WatchTerms, output_watched_within};
use crate::wait_ceilings::{OBJECT_STEP_CEILING, PLUMBING_STEP_CEILING};
use crate::private_storage::write_exclusive_scratch;
use crate::scratch_location::prepare_repository_scratch;
use crate::start_retry::{START_RETRY_BUDGET, retry_refused_start_with};
use crate::random_id::random_uuid;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::Duration;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitContext {
pub real_git: PathBuf,
pub overlay: Vec<(OsString, OsString)>,
pub global_prefix: Vec<OsString>,
pub common_dir: Option<PathBuf>,
}
#[derive(Clone, Debug, Default)]
pub struct GitRequest {
pub cwd: PathBuf,
pub arguments: Vec<OsString>,
pub without_prefix: bool,
pub index: Option<PathBuf>,
pub object_directory: Option<PathBuf>,
pub environment: Vec<(OsString, OsString)>,
pub unset: Vec<OsString>,
pub input: Option<Vec<u8>>,
}
impl GitRequest {
pub fn new<S: AsRef<OsStr>>(cwd: &Path, arguments: &[S]) -> Self {
let mut owned: Vec<OsString> = Vec::with_capacity(arguments.len());
for argument in arguments {
owned.push(argument.as_ref().to_os_string());
}
return Self {
cwd: cwd.to_path_buf(),
arguments: owned,
..Self::default()
};
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitOutput {
pub code: Option<i32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
impl GitOutput {
pub fn succeeded(&self) -> bool {
return self.code == Some(0);
}
pub fn error_text(&self) -> String {
return String::from_utf8_lossy(self.stderr.as_slice())
.trim()
.to_string();
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitFailure(pub String);
impl std::fmt::Display for GitFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str(self.0.as_str());
}
}
pub fn command_text(arguments: &[OsString]) -> String {
let mut text: String = String::from("git");
for argument in arguments {
text.push(' ');
text.push_str(argument.to_string_lossy().as_ref());
}
return text;
}
fn input_file(bytes: &[u8], common_dir: Option<&Path>) -> std::io::Result<std::fs::File> {
let Some(common) = common_dir else {
return Err(std::io::Error::other(
"cli-git has no repository scratch directory for the input of this Git command: the command was started \
before the repository was known, which is a defect in cli-git",
));
};
let directory: PathBuf = prepare_repository_scratch(common)?;
let unique: String = match random_uuid() {
Ok(id) => id,
Err(error) => return Err(std::io::Error::other(error.0)),
};
let path: PathBuf = directory.join(format!("cli-git-input-{unique}"));
write_exclusive_scratch(path.as_path(), bytes)?;
let file: std::io::Result<std::fs::File> = std::fs::File::open(&path);
let _ = std::fs::remove_file(&path);
let opened: std::fs::File = file?;
return Ok(opened);
}
fn build_command(context: &GitContext, request: &GitRequest) -> Command {
let mut arguments: Vec<OsString> = Vec::new();
if !request.without_prefix {
arguments.extend(context.global_prefix.iter().cloned());
}
arguments.extend(request.arguments.iter().cloned());
let mut command: Command = git_command(
context.real_git.as_path(),
arguments.as_slice(),
context.overlay.as_slice(),
);
command.current_dir(request.cwd.as_path());
for name in &request.unset {
command.env_remove(name);
}
for (name, value) in &request.environment {
command.env(name, value);
}
if let Some(index) = &request.index {
command.env("GIT_INDEX_FILE", index);
}
if let Some(objects) = &request.object_directory {
command.env("GIT_OBJECT_DIRECTORY", objects);
}
return command;
}
pub fn run_git(context: &GitContext, request: &GitRequest) -> std::io::Result<GitOutput> {
if let Some(answered) = try_request(context, request) {
return Ok(answered);
}
let label: String = crate::add_timing::git_step_label(request.arguments.as_slice());
return crate::add_timing::step(label.as_str(), || { return run_git_pausing(context, request, START_RETRY_BUDGET, &mut |wait: Duration| { std::thread::sleep(wait); }); });
}
const RERUNNABLE: [&str; 17] = [
"rev-parse",
"symbolic-ref",
"for-each-ref",
"ls-files",
"diff",
"diff-index",
"diff-tree",
"cat-file",
"show",
"hash-object",
"write-tree",
"ls-remote",
"merge-tree",
"init",
"pack-objects",
"index-pack",
"version",
];
pub(crate) fn subcommand_of(arguments: &[OsString]) -> Option<(usize, String)> {
let mut index: usize = 0;
while index < arguments.len() {
let text: String = arguments[index].to_string_lossy().into_owned();
if text == "-c" || text == "-C" {
index += 2;
continue;
}
if text.starts_with('-') {
index += 1;
continue;
}
return Some((index, text));
}
return None;
}
fn may_be_rerun(request: &GitRequest) -> bool {
let Some((position, name)) = subcommand_of(request.arguments.as_slice()) else {
return false;
};
let rest: Vec<String> = request.arguments[position + 1..]
.iter()
.map(|argument| { return argument.to_string_lossy().into_owned(); })
.collect();
match name.as_str() {
"config" => return rest.iter().any(|option| {
return matches!(option.as_str(), "--get" | "--get-all" | "--get-regexp" | "--list" | "-l");
}),
"remote" => return rest.first().is_some_and(|word| { return word == "get-url"; }),
"add" | "reset" | "read-tree" | "update-index" => return request.index.is_some(),
other => return RERUNNABLE.contains(&other),
}
}
fn refused_by_the_system(output: &GitOutput) -> bool {
return output.code != Some(0)
&& String::from_utf8_lossy(output.stderr.as_slice()).contains("Resource temporarily unavailable");
}
fn run_git_pausing(
context: &GitContext,
request: &GitRequest,
budget: Duration,
pause: &mut dyn FnMut(Duration),
) -> std::io::Result<GitOutput> {
if !may_be_rerun(request) {
return run_git_once(context, request);
}
let what: String = command_text(&request.arguments);
let mut refused: Option<GitOutput> = None;
let result: std::io::Result<GitOutput> = retry_refused_start_with(what.as_str(), budget, pause, &mut || {
let output: GitOutput = match run_git_once(context, request) {
Ok(finished) => finished,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Err(std::io::Error::other(error)),
Err(error) => return Err(error),
};
if refused_by_the_system(&output) {
refused = Some(output);
return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock));
}
return Ok(output);
});
match (result, refused.take()) {
(Ok(finished), _) => return Ok(finished),
(Err(error), Some(last)) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(last),
(Err(error), _) => return Err(error),
}
}
fn run_git_once(context: &GitContext, request: &GitRequest) -> std::io::Result<GitOutput> {
return run_git_once_with(context, request, &|| { return terms_for_request(request); });
}
pub(crate) fn terms_for_request(request: &GitRequest) -> WatchTerms {
return WatchTerms::for_class(class_of(request.arguments.as_slice()));
}
fn run_git_once_with(
context: &GitContext,
request: &GitRequest,
terms: &dyn Fn() -> WatchTerms,
) -> std::io::Result<GitOutput> {
let first: std::io::Result<GitOutput> = run_git_watched(context, request, terms());
return match first {
Err(error) if error.kind() == std::io::ErrorKind::TimedOut && may_be_rerun(request) && !crate::child_watch::is_ceiling(&error) => {
let _written = writeln!(
std::io::stderr(),
"cli-git: {} showed no sign of work and was ended; running it again",
step_subject(request)
);
run_git_watched(context, request, terms())
}
other => other,
};
}
fn step_subject(request: &GitRequest) -> String {
return format!(
"{}, {}",
command_text(&request.arguments),
class_of(request.arguments.as_slice()).label()
);
}
const READS_AND_OBJECT_WRITERS: [&str; 16] = [
"rev-list",
"merge-base",
"log",
"ls-tree",
"status",
"var",
"diff-files",
"check-attr",
"check-ignore",
"stripspace",
"fmt-merge-msg",
"show-ref",
"name-rev",
"commit-tree",
"fast-import",
"mktree",
];
struct Words<'a> {
options: Vec<&'a str>,
before: Vec<&'a str>,
after: Option<Vec<&'a str>>,
}
impl<'a> Words<'a> {
fn of(words: &'a [String]) -> Self {
let mut sorted: Words<'a> = Words { options: Vec::new(), before: Vec::new(), after: None };
for word in words.iter().map(String::as_str) {
match sorted.after.as_mut() {
Some(after) => after.push(word),
None if word == "--" => sorted.after = Some(Vec::new()),
None if word.starts_with('-') && word.len() > 1 => sorted.options.push(word),
None => sorted.before.push(word),
}
}
return sorted;
}
fn only_options(&self, allowed: &[&str]) -> bool {
return self.options.iter().all(|option| { return allowed.contains(option); });
}
fn has(&self, wanted: &str) -> bool {
return self.options.contains(&wanted);
}
}
fn is_side_ref(word: &str) -> bool {
return ["refs/cli-git/kept/", "refs/worktree/cli-git-service/"]
.iter()
.any(|prefix| { return word.strip_prefix(prefix).is_some_and(|number| { return !number.is_empty() && number.bytes().all(|byte| { return byte.is_ascii_digit(); }); }); });
}
fn is_object_id(word: &str) -> bool {
return matches!(word.len(), 40 | 64) && word.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
}
fn is_proven_to_run_twice(name: &str, rest: &[String]) -> bool {
let words: Words<'_> = Words::of(rest);
let no_paths = words.after.is_none();
return match name {
"update-ref" => {
let plain: bool = words.after.is_none() && words.only_options(&["-d"]);
let delete: bool = words.has("-d") && words.before.len() == 1 && is_side_ref(words.before[0]);
let set: bool = !words.has("-d") && words.before.len() == 2 && is_side_ref(words.before[0]) && is_object_id(words.before[1]);
plain && (delete || set)
}
"stash" => rest.first().is_some_and(|first| { return first == "list"; }) && rest.len() == 2 && rest[1] == "--format=%H",
"clean" => no_paths && words.before.is_empty() && words.only_options(&["--force", "-d"]) && words.has("--force") && words.has("-d"),
"read-tree" => no_paths && words.before.len() == 1 && words.only_options(&["--reset", "-u"]) && words.has("--reset") && words.has("-u"),
"update-index" => {
let refresh: bool = no_paths && words.before.is_empty() && words.options == ["--refresh"];
let index_info: bool = no_paths && words.before.is_empty() && words.has("--index-info") && words.only_options(&["--index-info", "-z"]);
let cache_info: bool = no_paths && words.before.len() == 1 && words.only_options(&["--add", "--cacheinfo"]) && words.has("--add") && words.has("--cacheinfo");
refresh || index_info || cache_info
}
"add" => {
let one_of: usize = ["--update", "-u", "--all", "-A"].iter().filter(|option| { return words.has(option); }).count();
words.only_options(&["--update", "-u", "--all", "-A"]) && one_of == 1
}
"checkout-index" => no_paths && words.before.is_empty() && words.only_options(&["--all", "-a", "--force", "-f"]) && (words.has("--all") || words.has("-a")) && (words.has("--force") || words.has("-f")),
"restore" => {
let options_known: bool = words.options.iter().all(|option| { return matches!(*option, "--staged" | "--worktree" | "--quiet" | "-q") || option.starts_with("--source="); });
options_known && words.before.is_empty() && words.after.as_ref().is_some_and(|paths| { return !paths.is_empty(); })
}
"reset" => words.only_options(&["--quiet", "-q"]) && words.before.len() == 1 && words.after.as_ref().is_some_and(|paths| { return !paths.is_empty(); }),
"config" => no_paths && words.only_options(&["--local"]) && words.before.len() == 2,
_ => false,
};
}
pub(crate) fn may_be_ended(request: &GitRequest) -> bool {
if may_be_rerun(request) {
return true;
}
let Some((position, name)) = subcommand_of(request.arguments.as_slice()) else {
return false;
};
if READS_AND_OBJECT_WRITERS.contains(&name.as_str()) {
return true;
}
let rest: Vec<String> = request.arguments[position + 1..].iter().map(|argument| { return argument.to_string_lossy().into_owned(); }).collect();
return is_proven_to_run_twice(name.as_str(), rest.as_slice());
}
fn step_bound(request: &GitRequest) -> StepBound {
#[cfg(test)]
let shortened: Option<Duration> = STEP_CEILING_FOR_THIS_THREAD.with(std::cell::Cell::get);
#[cfg(not(test))]
let shortened: Option<Duration> = None;
let class_ceiling: Duration = shortened.unwrap_or(match class_of(request.arguments.as_slice()) {
ChildClass::Plumbing => PLUMBING_STEP_CEILING,
ChildClass::Object => OBJECT_STEP_CEILING,
});
if may_be_ended(request) {
return StepBound::EndAt(class_ceiling);
}
if crate::service_busy::on_landing_thread() {
return StepBound::Unbounded;
}
#[cfg(test)]
let extra: Duration = EXTRA_WAIT_FOR_THIS_THREAD.with(std::cell::Cell::get).unwrap_or(crate::wait_ceilings::SHORT_EXTRA_WAIT);
#[cfg(not(test))]
let extra: Duration = crate::wait_ceilings::SHORT_EXTRA_WAIT;
return StepBound::LeaveRunning { after: class_ceiling, extra };
}
#[cfg(test)]
thread_local! {
pub(crate) static STEP_CEILING_FOR_THIS_THREAD: std::cell::Cell<Option<Duration>> = const { std::cell::Cell::new(None) };
pub(crate) static EXTRA_WAIT_FOR_THIS_THREAD: std::cell::Cell<Option<Duration>> = const { std::cell::Cell::new(None) };
}
fn run_git_watched(context: &GitContext, request: &GitRequest, terms: WatchTerms) -> std::io::Result<GitOutput> {
let mut command: Command = build_command(context, request);
match &request.input {
Some(bytes) => {
command.stdin(Stdio::from(input_file(bytes.as_slice(), context.common_dir.as_deref())?));
}
None => {
command.stdin(Stdio::null());
}
}
let subject: String = step_subject(request);
let ledger_step: String = crate::add_timing::git_step_label(request.arguments.as_slice());
let bound: StepBound = step_bound(request);
let _recorded = if matches!(bound, StepBound::Unbounded) {
crate::service_busy::enter_step(format!("{} in {}", command_text(&request.arguments), request.cwd.display()))
} else {
None
};
let output: std::process::Output =
output_watched_within(&mut command, subject.as_str(), ledger_step.as_str(), terms, may_be_rerun(request), bound)?;
return Ok(GitOutput {
code: output.status.code(),
stdout: output.stdout,
stderr: output.stderr,
});
}
pub fn run_git_checked(
context: &GitContext,
request: &GitRequest,
) -> Result<GitOutput, GitFailure> {
return checked_from(run_git(context, request), request.arguments.as_slice());
}
fn checked_from(outcome: std::io::Result<GitOutput>, arguments: &[OsString]) -> Result<GitOutput, GitFailure> {
let output: GitOutput = match outcome {
Ok(finished) => finished,
Err(error) if error.kind() == std::io::ErrorKind::TimedOut => {
return Err(GitFailure(error.to_string()));
}
Err(error) => {
return Err(GitFailure(format!("{} could not start: {error}", command_text(arguments))));
}
};
if !output.succeeded() {
return Err(GitFailure(format!("{} failed: {}", command_text(arguments), output.error_text())));
}
return Ok(output);
}
#[cfg(test)]
#[path = "transaction_git_tests.rs"]
mod tests;