use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::error::{Result, ShoreError};
use crate::git::command::{Ancestry, GitInventoryPath, GitReflogEntry, GitWorktree, RefEntry};
#[cfg(feature = "gix")]
pub(crate) mod gix;
pub(crate) mod subprocess;
#[cfg(feature = "gix")]
use gix::GixBackend;
use subprocess::SubprocessBackend;
pub(crate) trait GitBackend: Send + Sync {
fn worktree_root(&self, repo: &Path) -> Result<PathBuf>;
fn common_dir(&self, repo: &Path) -> Result<PathBuf>;
fn is_ancestor(
&self,
repo: &Path,
ancestor_oid: &str,
descendant_oid: &str,
) -> Result<Ancestry>;
fn independent_commits(&self, repo: &Path, oids: &[String]) -> Result<Vec<String>>;
fn commit_changed_paths(&self, repo: &Path, commit_oid: &str) -> Result<Vec<String>>;
fn commit_subjects(
&self,
repo: &Path,
commit_oids: &BTreeSet<String>,
) -> Result<BTreeMap<String, String>>;
fn for_each_ref(&self, repo: &Path, patterns: &[&str]) -> Result<Vec<RefEntry>>;
fn ref_state_lines(&self, repo: &Path) -> Result<String>;
fn object_exists(&self, repo: &Path, oid: &str) -> Result<bool>;
fn default_branch_ref(&self, repo: &Path) -> Result<Option<String>>;
fn rev_list_range(&self, repo: &Path, range: &str) -> Result<Vec<String>>;
fn rev_list_reachable(&self, repo: &Path, tips: &[String]) -> Result<HashSet<String>>;
fn rev_list_reflog_reachable(&self, repo: &Path) -> Result<HashSet<String>>;
fn reflog_entries(&self, repo: &Path, ref_name: &str) -> Result<Vec<GitReflogEntry>>;
fn worktree_list(&self, repo: &Path) -> Result<Vec<GitWorktree>>;
fn paths_are_ignored(&self, repo: &Path, pathspecs: &[&str]) -> Result<Vec<bool>>;
fn untracked_inventory(&self, repo: &Path) -> Result<Vec<GitInventoryPath>>;
fn tracked_and_untracked_inventory(&self, repo: &Path) -> Result<Vec<GitInventoryPath>>;
fn path_is_untracked(&self, repo: &Path, relative_path: &str) -> Result<bool>;
fn config_get(&self, repo: &Path, key: &str) -> Option<String>;
fn config_path_get(&self, repo: &Path, key: &str) -> Option<String>;
fn head_ref(&self, repo: &Path) -> Result<Option<String>>;
fn head_oid(&self, repo: &Path) -> Result<String>;
fn head_commit_oid_optional(&self, repo: &Path) -> Result<Option<String>>;
fn rev_parse_commit_oid(&self, repo: &Path, rev: &str) -> Result<String>;
fn commit_tree_oid(&self, repo: &Path, commit_oid: &str) -> Result<String>;
fn empty_tree_oid(&self, repo: &Path) -> Result<String>;
}
pub(crate) enum GitBackendKind {
Subprocess(SubprocessBackend),
#[cfg(feature = "gix")]
Gix(GixBackend),
}
impl GitBackendKind {
fn as_backend(&self) -> &dyn GitBackend {
match self {
GitBackendKind::Subprocess(backend) => {
#[cfg(test)]
subprocess::record_backend_tag(subprocess::BackendTag::Subprocess);
backend
}
#[cfg(feature = "gix")]
GitBackendKind::Gix(backend) => {
#[cfg(test)]
subprocess::record_backend_tag(subprocess::BackendTag::Gix);
backend
}
}
}
}
impl GitBackend for GitBackendKind {
fn worktree_root(&self, repo: &Path) -> Result<PathBuf> {
self.as_backend().worktree_root(repo)
}
fn common_dir(&self, repo: &Path) -> Result<PathBuf> {
self.as_backend().common_dir(repo)
}
fn is_ancestor(
&self,
repo: &Path,
ancestor_oid: &str,
descendant_oid: &str,
) -> Result<Ancestry> {
self.as_backend()
.is_ancestor(repo, ancestor_oid, descendant_oid)
}
fn independent_commits(&self, repo: &Path, oids: &[String]) -> Result<Vec<String>> {
self.as_backend().independent_commits(repo, oids)
}
fn commit_changed_paths(&self, repo: &Path, commit_oid: &str) -> Result<Vec<String>> {
self.as_backend().commit_changed_paths(repo, commit_oid)
}
fn commit_subjects(
&self,
repo: &Path,
commit_oids: &BTreeSet<String>,
) -> Result<BTreeMap<String, String>> {
self.as_backend().commit_subjects(repo, commit_oids)
}
fn for_each_ref(&self, repo: &Path, patterns: &[&str]) -> Result<Vec<RefEntry>> {
self.as_backend().for_each_ref(repo, patterns)
}
fn ref_state_lines(&self, repo: &Path) -> Result<String> {
self.as_backend().ref_state_lines(repo)
}
fn object_exists(&self, repo: &Path, oid: &str) -> Result<bool> {
self.as_backend().object_exists(repo, oid)
}
fn default_branch_ref(&self, repo: &Path) -> Result<Option<String>> {
self.as_backend().default_branch_ref(repo)
}
fn rev_list_range(&self, repo: &Path, range: &str) -> Result<Vec<String>> {
self.as_backend().rev_list_range(repo, range)
}
fn rev_list_reachable(&self, repo: &Path, tips: &[String]) -> Result<HashSet<String>> {
self.as_backend().rev_list_reachable(repo, tips)
}
fn rev_list_reflog_reachable(&self, repo: &Path) -> Result<HashSet<String>> {
self.as_backend().rev_list_reflog_reachable(repo)
}
fn reflog_entries(&self, repo: &Path, ref_name: &str) -> Result<Vec<GitReflogEntry>> {
self.as_backend().reflog_entries(repo, ref_name)
}
fn worktree_list(&self, repo: &Path) -> Result<Vec<GitWorktree>> {
self.as_backend().worktree_list(repo)
}
fn paths_are_ignored(&self, repo: &Path, pathspecs: &[&str]) -> Result<Vec<bool>> {
self.as_backend().paths_are_ignored(repo, pathspecs)
}
fn untracked_inventory(&self, repo: &Path) -> Result<Vec<GitInventoryPath>> {
self.as_backend().untracked_inventory(repo)
}
fn tracked_and_untracked_inventory(&self, repo: &Path) -> Result<Vec<GitInventoryPath>> {
self.as_backend().tracked_and_untracked_inventory(repo)
}
fn path_is_untracked(&self, repo: &Path, relative_path: &str) -> Result<bool> {
self.as_backend().path_is_untracked(repo, relative_path)
}
fn config_get(&self, repo: &Path, key: &str) -> Option<String> {
self.as_backend().config_get(repo, key)
}
fn config_path_get(&self, repo: &Path, key: &str) -> Option<String> {
self.as_backend().config_path_get(repo, key)
}
fn head_ref(&self, repo: &Path) -> Result<Option<String>> {
self.as_backend().head_ref(repo)
}
fn head_oid(&self, repo: &Path) -> Result<String> {
self.as_backend().head_oid(repo)
}
fn head_commit_oid_optional(&self, repo: &Path) -> Result<Option<String>> {
self.as_backend().head_commit_oid_optional(repo)
}
fn rev_parse_commit_oid(&self, repo: &Path, rev: &str) -> Result<String> {
self.as_backend().rev_parse_commit_oid(repo, rev)
}
fn commit_tree_oid(&self, repo: &Path, commit_oid: &str) -> Result<String> {
self.as_backend().commit_tree_oid(repo, commit_oid)
}
fn empty_tree_oid(&self, repo: &Path) -> Result<String> {
self.as_backend().empty_tree_oid(repo)
}
}
static SUBPROCESS_KIND: GitBackendKind = GitBackendKind::Subprocess(SubprocessBackend);
#[cfg(feature = "gix")]
static GIX_KIND: GitBackendKind = GitBackendKind::Gix(GixBackend);
static SUBPROCESS_BACKEND: SubprocessBackend = SubprocessBackend;
const POINTBREAK_GIT_BACKEND: &str = "POINTBREAK_GIT_BACKEND";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum BackendSelector {
Compiled,
ForceSubprocess,
#[cfg_attr(not(feature = "gix"), allow(dead_code))]
ForceGix,
}
fn parse_selector(raw: Option<&OsStr>) -> Result<BackendSelector> {
let Some(value) = raw else {
return Ok(BackendSelector::Compiled);
};
let Some(text) = value.to_str() else {
return Err(ShoreError::Message(format!(
"{POINTBREAK_GIT_BACKEND} is not valid UTF-8; set it to 'subprocess' or 'gix'"
)));
};
match text {
"subprocess" => Ok(BackendSelector::ForceSubprocess),
#[cfg(feature = "gix")]
"gix" => Ok(BackendSelector::ForceGix),
#[cfg(not(feature = "gix"))]
"gix" => Err(ShoreError::Message(format!(
"{POINTBREAK_GIT_BACKEND}=gix but this build was compiled without the gix backend"
))),
other => Err(ShoreError::Message(format!(
"{POINTBREAK_GIT_BACKEND}={other:?} is not a known git backend \
(expected 'subprocess' or 'gix')"
))),
}
}
fn selector() -> Result<BackendSelector> {
#[cfg(test)]
if let Some(injected) = INJECTED_SELECTOR.with(std::cell::Cell::get) {
return Ok(injected);
}
static CACHED: OnceLock<std::result::Result<BackendSelector, String>> = OnceLock::new();
CACHED
.get_or_init(|| {
parse_selector(std::env::var_os(POINTBREAK_GIT_BACKEND).as_deref())
.map_err(|error| error.to_string())
})
.clone()
.map_err(ShoreError::Message)
}
#[doc(hidden)]
pub fn validate_backend_selector() -> Result<()> {
selector().map(|_| ())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub(crate) enum BackendClass {
ReadGraphRefs,
ReadIgnore,
ReadInventory,
ReadConfigDiscovery,
ReadRepoDiscovery,
IdentityScalars,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RoutedBackend {
Subprocess,
#[cfg_attr(not(feature = "gix"), allow(dead_code))]
Gix,
}
const DEFAULT_READ_GRAPH_REFS: RoutedBackend = RoutedBackend::Gix;
const DEFAULT_READ_IGNORE: RoutedBackend = RoutedBackend::Gix;
const DEFAULT_READ_INVENTORY: RoutedBackend = RoutedBackend::Gix;
const DEFAULT_READ_CONFIG_DISCOVERY: RoutedBackend = RoutedBackend::Subprocess;
const DEFAULT_READ_REPO_DISCOVERY: RoutedBackend = RoutedBackend::Gix;
const DEFAULT_IDENTITY_SCALARS: RoutedBackend = RoutedBackend::Gix;
fn class_default(class: BackendClass) -> RoutedBackend {
match class {
BackendClass::ReadGraphRefs => DEFAULT_READ_GRAPH_REFS,
BackendClass::ReadIgnore => DEFAULT_READ_IGNORE,
BackendClass::ReadInventory => DEFAULT_READ_INVENTORY,
BackendClass::ReadConfigDiscovery => DEFAULT_READ_CONFIG_DISCOVERY,
BackendClass::ReadRepoDiscovery => DEFAULT_READ_REPO_DISCOVERY,
BackendClass::IdentityScalars => DEFAULT_IDENTITY_SCALARS,
}
}
fn routed_backend(class: BackendClass) -> Result<RoutedBackend> {
Ok(match selector()? {
BackendSelector::ForceSubprocess => RoutedBackend::Subprocess,
#[cfg(feature = "gix")]
BackendSelector::ForceGix => RoutedBackend::Gix,
#[cfg(not(feature = "gix"))]
BackendSelector::ForceGix => {
return Err(ShoreError::Message(format!(
"{POINTBREAK_GIT_BACKEND}=gix but this build was compiled without the gix backend"
)));
}
BackendSelector::Compiled => class_default(class),
})
}
pub(crate) fn dispatch(class: BackendClass) -> Result<&'static GitBackendKind> {
match routed_backend(class)? {
RoutedBackend::Subprocess => Ok(&SUBPROCESS_KIND),
#[cfg(feature = "gix")]
RoutedBackend::Gix => Ok(&GIX_KIND),
#[cfg(not(feature = "gix"))]
RoutedBackend::Gix => Ok(&SUBPROCESS_KIND),
}
}
#[cfg(all(test, feature = "gix-parity"))]
pub(crate) fn backend_class_for_name(name: &str) -> BackendClass {
match name {
"read:graph-refs" => BackendClass::ReadGraphRefs,
"read:ignore" => BackendClass::ReadIgnore,
"read:inventory" => BackendClass::ReadInventory,
"read:config-discovery" => BackendClass::ReadConfigDiscovery,
"read:repo-discovery" => BackendClass::ReadRepoDiscovery,
"identity-scalars" => BackendClass::IdentityScalars,
other => panic!("unknown git-backend parity class name: {other:?}"),
}
}
#[cfg(all(test, feature = "gix-parity"))]
pub(crate) fn is_gix_qualified(name: &str) -> bool {
class_default(backend_class_for_name(name)) == RoutedBackend::Gix
}
pub(crate) fn subprocess_backend() -> &'static SubprocessBackend {
&SUBPROCESS_BACKEND
}
#[cfg(test)]
thread_local! {
static INJECTED_SELECTOR: std::cell::Cell<Option<BackendSelector>> =
const { std::cell::Cell::new(None) };
}
#[cfg(test)]
pub(crate) fn inject_selector(selector: BackendSelector) {
INJECTED_SELECTOR.with(|cell| cell.set(Some(selector)));
}
#[cfg(test)]
pub(crate) fn reset_selector() {
INJECTED_SELECTOR.with(|cell| cell.set(None));
}
#[cfg(test)]
mod tests {
use subprocess::run_git;
use tempfile::TempDir;
use super::*;
fn init_repo() -> TempDir {
let dir = TempDir::new().expect("create temp git repository directory");
run_git(dir.path(), ["init"]).unwrap();
run_git(dir.path(), ["config", "user.name", "Shore Tests"]).unwrap();
run_git(
dir.path(),
["config", "user.email", "shore-tests@example.com"],
)
.unwrap();
run_git(dir.path(), ["config", "commit.gpgsign", "false"]).unwrap();
std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
run_git(dir.path(), ["add", "--all"]).unwrap();
run_git(dir.path(), ["commit", "-m", "first"]).unwrap();
dir
}
#[test]
fn subprocess_backend_resolves_discovery_and_graph() {
let repo = init_repo();
let backend = SubprocessBackend;
let root = backend.worktree_root(repo.path()).unwrap();
assert_eq!(
root.canonicalize().unwrap(),
repo.path().canonicalize().unwrap()
);
assert!(backend.common_dir(repo.path()).is_ok());
let entries = backend.for_each_ref(repo.path(), &["refs/heads/"]).unwrap();
assert!(
entries
.iter()
.any(|entry| entry.name.starts_with("refs/heads/"))
);
}
#[test]
fn dispatch_routes_through_the_subprocess_backend() {
let repo = init_repo();
#[cfg(feature = "gix")]
inject_selector(BackendSelector::ForceSubprocess);
assert!(
dispatch(BackendClass::IdentityScalars)
.unwrap()
.worktree_root(repo.path())
.is_ok()
);
assert!(
dispatch(BackendClass::ReadGraphRefs)
.unwrap()
.for_each_ref(repo.path(), &["refs/heads/"])
.is_ok()
);
#[cfg(feature = "gix")]
reset_selector();
}
#[test]
fn selector_rejects_bad_values_and_feature_off_gix() {
assert!(parse_selector(Some(OsStr::new("libgit2"))).is_err());
assert!(parse_selector(Some(OsStr::new(""))).is_err());
assert_eq!(parse_selector(None).unwrap(), BackendSelector::Compiled);
assert_eq!(
parse_selector(Some(OsStr::new("subprocess"))).unwrap(),
BackendSelector::ForceSubprocess
);
#[cfg(not(feature = "gix"))]
assert!(parse_selector(Some(OsStr::new("gix"))).is_err());
#[cfg(feature = "gix")]
assert_eq!(
parse_selector(Some(OsStr::new("gix"))).unwrap(),
BackendSelector::ForceGix
);
}
#[cfg(not(feature = "gix"))]
#[test]
fn dispatch_rejects_feature_off_force_gix() {
inject_selector(BackendSelector::ForceGix);
assert!(dispatch(BackendClass::ReadGraphRefs).is_err());
reset_selector();
}
#[cfg(feature = "gix")]
#[test]
fn identity_scalars_route_to_gix_by_default() {
inject_selector(BackendSelector::Compiled);
assert_eq!(
routed_backend(BackendClass::IdentityScalars).unwrap(),
RoutedBackend::Gix
);
reset_selector();
}
#[cfg(feature = "gix")]
#[test]
fn compiled_defaults_route_qualified_classes_to_gix() {
inject_selector(BackendSelector::Compiled);
for class in [
BackendClass::ReadIgnore,
BackendClass::ReadGraphRefs,
BackendClass::ReadInventory,
BackendClass::ReadRepoDiscovery,
BackendClass::IdentityScalars,
] {
assert_eq!(
routed_backend(class).unwrap(),
RoutedBackend::Gix,
"{class:?} is qualified to gix"
);
}
assert_eq!(
routed_backend(BackendClass::ReadConfigDiscovery).unwrap(),
RoutedBackend::Subprocess,
"config-discovery stays on subprocess"
);
reset_selector();
}
#[cfg(not(feature = "gix"))]
#[test]
fn default_build_qualified_class_stays_subprocess() {
inject_selector(BackendSelector::Compiled);
let repo = init_repo();
subprocess::reset_backend_tag();
let _ = dispatch(BackendClass::ReadIgnore)
.unwrap()
.paths_are_ignored(repo.path(), &["file.txt"]);
assert_eq!(
subprocess::last_backend_tag(),
Some(subprocess::BackendTag::Subprocess)
);
reset_selector();
}
#[cfg(feature = "gix")]
#[test]
fn force_gix_routes_every_class_to_gix() {
inject_selector(BackendSelector::ForceGix);
assert_eq!(
routed_backend(BackendClass::ReadGraphRefs).unwrap(),
RoutedBackend::Gix
);
assert_eq!(
routed_backend(BackendClass::IdentityScalars).unwrap(),
RoutedBackend::Gix
);
reset_selector();
}
#[cfg(feature = "gix-parity")]
#[test]
fn backend_class_for_name_covers_every_harness_class_name() {
use std::collections::HashSet;
let names = [
"read:graph-refs",
"read:ignore",
"read:inventory",
"read:config-discovery",
"read:repo-discovery",
"identity-scalars",
];
let classes: HashSet<_> = names
.iter()
.map(|name| backend_class_for_name(name))
.collect();
assert_eq!(
classes.len(),
6,
"each harness class name maps to a distinct BackendClass"
);
}
}