use super::{DetailedSubmoduleStatus, GitConfig, GitOperations, SubmoduleStatusFlags};
use crate::config::{
SubmoduleAddOptions, SubmoduleEntries, SubmoduleEntry, SubmoduleUpdateOptions,
};
use crate::options::{
ConfigLevel, SerializableBranch, SerializableFetchRecurse, SerializableIgnore,
SerializableUpdate,
};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::Path;
pub struct Git2Operations {
repo: git2::Repository,
}
impl Git2Operations {
pub fn new(repo_path: Option<&Path>) -> Result<Self> {
let repo = match repo_path {
Some(path) => git2::Repository::open(path)
.with_context(|| format!("Failed to open repository at {}", path.display()))?,
None => git2::Repository::open_from_env()
.with_context(|| "Failed to open repository from environment")?,
};
Ok(Self { repo })
}
pub(super) fn workdir(&self) -> Option<&std::path::Path> {
self.repo.workdir()
}
fn convert_git2_submodule_to_entry(
&self,
submodule: &git2::Submodule,
) -> Result<(String, SubmoduleEntry)> {
let name = submodule.name().unwrap_or("").to_string();
let path = submodule.path().to_string_lossy().to_string();
let url = submodule.url().unwrap_or("").to_string();
let branch = self.get_submodule_branch(&name)?;
let ignore = submodule.ignore_rule().try_into().ok();
let update = submodule.update_strategy().try_into().ok();
let fetch_recurse = self.get_submodule_fetch_recurse(&name)?;
let active = self.is_submodule_active(&name)?;
let shallow = self.is_submodule_shallow(&path)?;
let entry = SubmoduleEntry {
path: Some(path),
url: Some(url),
branch,
ignore,
update,
fetch_recurse,
active: Some(active),
shallow: Some(shallow),
no_init: Some(false), sparse_paths: None,
use_git_default_sparse_checkout: None,
};
Ok((name, entry))
}
fn get_submodule_branch(&self, name: &str) -> Result<Option<SerializableBranch>> {
let config = self.repo.config()?;
let key = format!("submodule.{name}.branch");
match config.get_string(&key) {
Ok(branch_str) => {
if branch_str == "." {
Ok(Some(SerializableBranch::CurrentInSuperproject))
} else {
Ok(Some(SerializableBranch::Name(branch_str)))
}
}
Err(_) => Ok(None),
}
}
fn get_submodule_fetch_recurse(&self, name: &str) -> Result<Option<SerializableFetchRecurse>> {
let config = self.repo.config()?;
let key = format!("submodule.{name}.fetchRecurseSubmodules");
match config.get_string(&key) {
Ok(fetch_str) => match fetch_str.as_str() {
"true" => Ok(Some(SerializableFetchRecurse::Always)),
"on-demand" => Ok(Some(SerializableFetchRecurse::OnDemand)),
"false" | "no" => Ok(Some(SerializableFetchRecurse::Never)),
_ => Ok(None),
},
Err(_) => Ok(None),
}
}
fn is_submodule_active(&self, name: &str) -> Result<bool> {
let config = self.repo.config()?;
let key = format!("submodule.{name}.active");
match config.get_bool(&key) {
Ok(active) => Ok(active),
Err(_) => Ok(true), }
}
fn is_submodule_shallow(&self, path: &str) -> Result<bool> {
let submodule_path = self
.repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?
.join(path);
if !submodule_path.exists() {
return Ok(false);
}
let shallow_file = submodule_path.join(".git").join("shallow");
Ok(shallow_file.exists())
}
#[allow(dead_code)]
fn convert_git2_status_to_flags(&self, status: git2::SubmoduleStatus) -> SubmoduleStatusFlags {
let mut flags = SubmoduleStatusFlags::empty();
if status.contains(git2::SubmoduleStatus::IN_HEAD) {
flags |= SubmoduleStatusFlags::IN_HEAD;
}
if status.contains(git2::SubmoduleStatus::IN_INDEX) {
flags |= SubmoduleStatusFlags::IN_INDEX;
}
if status.contains(git2::SubmoduleStatus::IN_CONFIG) {
flags |= SubmoduleStatusFlags::IN_CONFIG;
}
if status.contains(git2::SubmoduleStatus::IN_WD) {
flags |= SubmoduleStatusFlags::IN_WD;
}
if status.contains(git2::SubmoduleStatus::INDEX_ADDED) {
flags |= SubmoduleStatusFlags::INDEX_ADDED;
}
if status.contains(git2::SubmoduleStatus::INDEX_DELETED) {
flags |= SubmoduleStatusFlags::INDEX_DELETED;
}
if status.contains(git2::SubmoduleStatus::INDEX_MODIFIED) {
flags |= SubmoduleStatusFlags::INDEX_MODIFIED;
}
if status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED) {
flags |= SubmoduleStatusFlags::WD_UNINITIALIZED;
}
if status.contains(git2::SubmoduleStatus::WD_ADDED) {
flags |= SubmoduleStatusFlags::WD_ADDED;
}
if status.contains(git2::SubmoduleStatus::WD_DELETED) {
flags |= SubmoduleStatusFlags::WD_DELETED;
}
if status.contains(git2::SubmoduleStatus::WD_MODIFIED) {
flags |= SubmoduleStatusFlags::WD_MODIFIED;
}
if status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED) {
flags |= SubmoduleStatusFlags::WD_INDEX_MODIFIED;
}
if status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED) {
flags |= SubmoduleStatusFlags::WD_WD_MODIFIED;
}
if status.contains(git2::SubmoduleStatus::WD_UNTRACKED) {
flags |= SubmoduleStatusFlags::WD_UNTRACKED;
}
flags
}
#[allow(dead_code)]
fn get_config_at_level(&self, level: ConfigLevel) -> Result<git2::Config> {
match level {
ConfigLevel::Local => self.repo.config(),
ConfigLevel::Global => git2::Config::open_default()
.and_then(|config| config.open_level(git2::ConfigLevel::Global)),
ConfigLevel::System => git2::Config::open_default()
.and_then(|config| config.open_level(git2::ConfigLevel::System)),
ConfigLevel::Worktree => {
self.repo.config()
}
}
.with_context(|| format!("Failed to open config at level {level:?}"))
}
}
impl GitOperations for Git2Operations {
fn read_gitmodules(&self) -> Result<SubmoduleEntries> {
let mut submodules = HashMap::new();
self.repo
.submodules()?
.into_iter()
.try_for_each(|submodule| -> Result<()> {
let (name, entry) = self.convert_git2_submodule_to_entry(&submodule)?;
submodules.insert(name, entry);
Ok(())
})?;
Ok(SubmoduleEntries::new(
if submodules.is_empty() {
None
} else {
Some(submodules)
},
None, ))
}
fn write_gitmodules(&mut self, config: &SubmoduleEntries) -> Result<()> {
if let Some(submodules) = config.submodules().as_ref() {
for (name, entry) in *submodules {
match self.repo.find_submodule(
&entry
.path
.as_ref()
.map(std::string::ToString::to_string)
.unwrap_or(name.clone()),
) {
Ok(mut submodule) => {
let mut config = self.repo.config()?;
if let Some(ignore) = &entry.ignore {
let ignore_str = match ignore {
SerializableIgnore::All => "all",
SerializableIgnore::Dirty => "dirty",
SerializableIgnore::Untracked => "untracked",
SerializableIgnore::None => "none",
SerializableIgnore::Unspecified => continue, };
config.set_str(&format!("submodule.{name}.ignore"), ignore_str)?;
}
if let Some(update) = &entry.update {
let update_str = match update {
SerializableUpdate::Checkout => "checkout",
SerializableUpdate::Rebase => "rebase",
SerializableUpdate::Merge => "merge",
SerializableUpdate::None => "none",
SerializableUpdate::Unspecified => continue, };
config.set_str(&format!("submodule.{name}.update"), update_str)?;
}
if let Some(url) = &entry.url
&& submodule.url() != Some(url.as_str()) {
config.set_str(&format!("submodule.{name}.url"), url)?;
}
submodule.sync()?;
}
Err(_) => {
continue;
}
}
}
}
Ok(())
}
fn read_git_config(&self, level: ConfigLevel) -> Result<GitConfig> {
let config = self.get_config_at_level(level)?;
let mut entries = HashMap::new();
config.entries(None)?.for_each(|entry| {
if let (Some(name), Some(value)) = (entry.name(), entry.value()) {
entries.insert(name.to_string(), value.to_string());
}
})?;
Ok(GitConfig { entries })
}
fn write_git_config(&self, config: &GitConfig, level: ConfigLevel) -> Result<()> {
let mut git_config = self.get_config_at_level(level)?;
for (key, value) in &config.entries {
git_config.set_str(key, value)?;
}
Ok(())
}
fn set_config_value(&self, key: &str, value: &str, level: ConfigLevel) -> Result<()> {
let mut config = self.get_config_at_level(level)?;
config
.set_str(key, value)
.with_context(|| format!("Failed to set config value {key}={value}"))?;
Ok(())
}
fn add_submodule(&mut self, opts: &SubmoduleAddOptions) -> Result<()> {
let mut sub = self
.repo
.submodule(&opts.url, opts.path.as_path(), true)
.with_context(|| {
format!(
"Failed to create submodule entry for '{}' from '{}'",
opts.name, opts.url
)
})?;
let mut update_opts = git2::SubmoduleUpdateOptions::new();
let mut fetch_opts = git2::FetchOptions::new();
if opts.shallow {
fetch_opts.depth(1);
}
update_opts.fetch(fetch_opts);
sub.clone(Some(&mut update_opts)).with_context(|| {
format!(
"Failed to clone submodule '{}' from '{}'",
opts.name, opts.url
)
})?;
sub.add_to_index(true)
.with_context(|| format!("Failed to add submodule '{}' to index", opts.name))?;
sub.add_finalize()
.with_context(|| format!("Failed to finalize submodule '{}'", opts.name))?;
let path_str = opts.path.to_string_lossy();
let mut config = self
.repo
.config()
.with_context(|| "Failed to open git config")?;
if let Some(branch) = &opts.branch {
let branch_key = format!("submodule.{path_str}.branch");
config
.set_str(&branch_key, &branch.to_string())
.with_context(|| format!("Failed to set branch for submodule '{}'", opts.name))?;
}
if let Some(ignore) = &opts.ignore
&& !matches!(ignore, SerializableIgnore::Unspecified) {
let ignore_key = format!("submodule.{path_str}.ignore");
config
.set_str(&ignore_key, &ignore.to_string())
.with_context(|| {
format!("Failed to set ignore for submodule '{}'", opts.name)
})?;
}
if let Some(fetch_recurse) = &opts.fetch_recurse
&& !matches!(fetch_recurse, SerializableFetchRecurse::Unspecified) {
let fetch_key = format!("submodule.{path_str}.fetchRecurseSubmodules");
config
.set_str(&fetch_key, &fetch_recurse.to_string())
.with_context(|| {
format!("Failed to set fetchRecurse for submodule '{}'", opts.name)
})?;
}
if let Some(update) = &opts.update
&& !matches!(update, SerializableUpdate::Unspecified) {
let update_key = format!("submodule.{path_str}.update");
config
.set_str(&update_key, &update.to_string())
.with_context(|| {
format!("Failed to set update for submodule '{}'", opts.name)
})?;
}
Ok(())
}
fn init_submodule(&mut self, path: &str) -> Result<()> {
let mut submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
submodule.init(false)?; Ok(())
}
fn update_submodule(&mut self, path: &str, opts: &SubmoduleUpdateOptions) -> Result<()> {
let mut submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
let mut update_opts = git2::SubmoduleUpdateOptions::new();
update_opts.allow_fetch(true);
match opts.strategy {
SerializableUpdate::Checkout => {
}
SerializableUpdate::Rebase | SerializableUpdate::Merge => {
eprintln!(
"Warning: git2 doesn't support rebase/merge update strategies, using checkout"
);
}
SerializableUpdate::None => return Ok(()),
SerializableUpdate::Unspecified => {
}
}
submodule.update(true, Some(&mut update_opts))?;
Ok(())
}
fn delete_submodule(&mut self, path: &str) -> Result<()> {
self.deinit_submodule(path, true)?;
let mut index = self.repo.index()?;
index.remove_path(Path::new(path))?;
index.write()?;
let workdir = self
.repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?;
let submodule_path = workdir.join(path);
if submodule_path.exists() {
std::fs::remove_dir_all(&submodule_path)
.with_context(|| format!("Failed to remove submodule directory: {path}"))?;
}
Ok(())
}
fn deinit_submodule(&mut self, path: &str, force: bool) -> Result<()> {
let submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
let mut config = self.repo.config()?;
let name = submodule.name().unwrap_or(path);
let keys_to_remove = [
format!("submodule.{name}.url"),
format!("submodule.{name}.active"),
format!("submodule.{name}.branch"),
format!("submodule.{name}.fetchRecurseSubmodules"),
];
for key in &keys_to_remove {
let _ = config.remove(key); }
if force {
let workdir = self
.repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?;
let submodule_path = workdir.join(path);
if submodule_path.exists() {
std::fs::remove_dir_all(&submodule_path)
.with_context(|| format!("Failed to remove submodule directory: {path}"))?;
}
}
Ok(())
}
fn get_submodule_status(&self, path: &str) -> Result<DetailedSubmoduleStatus> {
let submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
let name = submodule.name().unwrap_or(path).to_string();
let url = submodule.url().map(std::string::ToString::to_string);
let status = self
.repo
.submodule_status(path, git2::SubmoduleIgnore::Unspecified)?;
let status_flags = self.convert_git2_status_to_flags(status);
let head_oid = submodule.head_id().map(|oid| oid.to_string());
let index_oid = submodule.index_id().map(|oid| oid.to_string());
let workdir_oid = submodule.workdir_id().map(|oid| oid.to_string());
let branch = self.get_submodule_branch(&name)?;
let ignore_rule = submodule.ignore_rule().try_into().unwrap_or_default();
let update_rule = submodule.update_strategy().try_into().unwrap_or_default();
let fetch_recurse_rule = self.get_submodule_fetch_recurse(&name)?.unwrap_or_default();
let is_initialized = !status.contains(git2::SubmoduleStatus::WD_UNINITIALIZED);
let is_active = self.is_submodule_active(&name)?;
let has_modifications = status.intersects(
git2::SubmoduleStatus::WD_MODIFIED
| git2::SubmoduleStatus::WD_INDEX_MODIFIED
| git2::SubmoduleStatus::WD_WD_MODIFIED,
);
let (sparse_checkout_enabled, sparse_patterns) = self.get_sparse_checkout_info(path)?;
Ok(DetailedSubmoduleStatus {
path: path.to_string(),
name,
url,
head_oid,
index_oid,
workdir_oid,
status_flags,
ignore_rule,
update_rule,
fetch_recurse_rule,
branch,
is_initialized,
is_active,
has_modifications,
sparse_checkout_enabled,
sparse_patterns,
})
}
fn list_submodules(&self) -> Result<Vec<String>> {
let submodules = self.repo.submodules()?;
let paths = submodules
.iter()
.map(|sm| sm.path().to_string_lossy().to_string())
.collect();
Ok(paths)
}
fn fetch_submodule(&self, path: &str) -> Result<()> {
let submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
let sub_repo = submodule
.open()
.with_context(|| format!("Failed to open submodule repository: {path}"))?;
let mut remote = sub_repo
.find_remote("origin")
.with_context(|| format!("Failed to find origin remote for submodule: {path}"))?;
remote
.fetch(&[] as &[&str], None, None)
.with_context(|| format!("Failed to fetch submodule: {path}"))?;
Ok(())
}
fn reset_submodule(&self, path: &str, hard: bool) -> Result<()> {
let submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
let sub_repo = submodule
.open()
.with_context(|| format!("Failed to open submodule repository: {path}"))?;
let head = sub_repo.head()?;
let commit = head.peel_to_commit()?;
let reset_type = if hard {
git2::ResetType::Hard
} else {
git2::ResetType::Soft
};
sub_repo
.reset(commit.as_object(), reset_type, None)
.with_context(|| format!("Failed to reset submodule: {path}"))?;
Ok(())
}
fn clean_submodule(&self, path: &str, force: bool, remove_directories: bool) -> Result<()> {
let submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
let sub_repo = submodule
.open()
.with_context(|| format!("Failed to open submodule repository: {path}"))?;
let mut status_opts = git2::StatusOptions::new();
status_opts.include_untracked(true);
status_opts.include_ignored(false);
let statuses = sub_repo.statuses(Some(&mut status_opts))?;
for entry in statuses.iter() {
if entry.status().is_wt_new()
&& let Some(file_path) = entry.path() {
let full_path = sub_repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("Submodule has no working directory"))?
.join(file_path);
if full_path.is_file() {
if force {
std::fs::remove_file(&full_path).with_context(|| {
format!("Failed to remove file: {}", full_path.display())
})?;
}
} else if full_path.is_dir() && remove_directories
&& force {
std::fs::remove_dir_all(&full_path).with_context(|| {
format!("Failed to remove directory: {}", full_path.display())
})?;
}
}
}
Ok(())
}
fn stash_submodule(&self, path: &str, include_untracked: bool) -> Result<()> {
let submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
let mut sub_repo = submodule
.open()
.with_context(|| format!("Failed to open submodule repository: {path}"))?;
let signature = sub_repo
.signature()
.or_else(|_| git2::Signature::now("submod", "submod@localhost"))?;
let mut stash_flags = git2::StashFlags::DEFAULT;
if include_untracked {
stash_flags |= git2::StashFlags::INCLUDE_UNTRACKED;
}
sub_repo
.stash_save(&signature, "submod stash", Some(stash_flags))
.with_context(|| format!("Failed to stash changes in submodule: {path}"))?;
Ok(())
}
fn enable_sparse_checkout(&self, path: &str) -> Result<()> {
let submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
let sub_repo = submodule
.open()
.with_context(|| format!("Failed to open submodule repository: {path}"))?;
let mut config = sub_repo.config()?;
config
.set_bool("core.sparseCheckout", true)
.with_context(|| format!("Failed to enable sparse checkout for submodule: {path}"))?;
Ok(())
}
fn set_sparse_patterns(&self, path: &str, patterns: &[String]) -> Result<()> {
let submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
let sub_repo = submodule
.open()
.with_context(|| format!("Failed to open submodule repository: {path}"))?;
let git_dir = sub_repo.path();
let sparse_checkout_file = git_dir.join("info").join("sparse-checkout");
if let Some(parent) = sparse_checkout_file.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create info directory for submodule: {path}")
})?;
}
let content = patterns.join("\n");
std::fs::write(&sparse_checkout_file, content).with_context(|| {
format!(
"Failed to write sparse checkout patterns for submodule: {path}"
)
})?;
Ok(())
}
fn get_sparse_patterns(&self, path: &str) -> Result<Vec<String>> {
let submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
let sub_repo = submodule
.open()
.with_context(|| format!("Failed to open submodule repository: {path}"))?;
let git_dir = sub_repo.path();
let sparse_checkout_file = git_dir.join("info").join("sparse-checkout");
if !sparse_checkout_file.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&sparse_checkout_file).with_context(|| {
format!(
"Failed to read sparse checkout patterns for submodule: {path}"
)
})?;
let patterns = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(std::string::ToString::to_string)
.collect();
Ok(patterns)
}
fn apply_sparse_checkout(&self, _path: &str) -> Result<()> {
Err(anyhow::anyhow!(
"git2 sparse checkout application not implemented, consider using gix_command"
))
}
}
impl Git2Operations {
#[allow(dead_code)]
fn get_sparse_checkout_info(&self, path: &str) -> Result<(bool, Vec<String>)> {
let submodule = self
.repo
.find_submodule(path)
.with_context(|| format!("Submodule not found: {path}"))?;
let sub_repo = submodule
.open()
.with_context(|| format!("Failed to open submodule repository: {path}"))?;
let config = sub_repo.config()?;
let sparse_enabled = config.get_bool("core.sparseCheckout").unwrap_or(false);
if !sparse_enabled {
return Ok((false, Vec::new()));
}
let patterns = self.get_sparse_patterns(path)?;
Ok((true, patterns))
}
}
impl From<super::GitOpsManager> for Git2Operations {
fn from(ops: super::GitOpsManager) -> Self {
ops.git2_ops
}
}