use super::{DetailedSubmoduleStatus, GitConfig, GitOperations, SubmoduleStatusFlags};
use crate::config::{
SubmoduleAddOptions, SubmoduleEntries, SubmoduleEntry, SubmoduleUpdateOptions,
};
use crate::options::{
ConfigLevel, GitmodulesConvert, 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().ok().flatten().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");
config.get_string(&key).map_or_else(
|_| Ok(None),
|branch_str| {
if branch_str == "." {
Ok(Some(SerializableBranch::CurrentInSuperproject))
} else {
Ok(Some(SerializableBranch::Name(branch_str)))
}
},
)
}
fn get_submodule_fetch_recurse(&self, name: &str) -> Result<Option<SerializableFetchRecurse>> {
let config = self.repo.config()?;
let key = format!("submodule.{name}.fetchRecurseSubmodules");
config.get_string(&key).map_or_else(
|_| Ok(None),
|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),
},
)
}
fn is_submodule_active(&self, name: &str) -> Result<bool> {
let config = self.repo.config()?;
let key = format!("submodule.{name}.active");
config.get_bool(&key).map_or(Ok(true), Ok)
}
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, clippy::unused_self)]
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<()> {
let workdir = self
.repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("Repository has no working directory"))?;
let gitmodules_path = workdir.join(".gitmodules");
if !gitmodules_path.exists() {
std::fs::write(&gitmodules_path, "")?;
}
let mut gitmodules_config = git2::Config::open(&gitmodules_path)?;
for (name, entry) in config.submodule_iter() {
let section = format!("submodule.{name}");
if let Some(path) = &entry.path {
gitmodules_config.set_str(&format!("{section}.path"), path)?;
}
if let Some(url) = &entry.url {
gitmodules_config.set_str(&format!("{section}.url"), url)?;
}
if let Some(branch) = &entry.branch {
gitmodules_config.set_str(&format!("{section}.branch"), &branch.to_string())?;
}
if let Some(update) = &entry.update {
gitmodules_config.set_str(&format!("{section}.update"), &update.to_gitmodules())?;
}
if let Some(ignore) = &entry.ignore {
gitmodules_config.set_str(&format!("{section}.ignore"), &ignore.to_gitmodules())?;
}
if let Some(fetch_recurse) = &entry.fetch_recurse {
gitmodules_config.set_str(
&format!("{section}.fetchRecurseSubmodules"),
&fetch_recurse.to_gitmodules(),
)?;
}
if let Some(active) = entry.active {
gitmodules_config.set_str(
&format!("{section}.active"),
if active { "true" } else { "false" },
)?;
}
if let Ok(mut submodule) = self
.repo
.find_submodule(entry.path.as_deref().unwrap_or(name))
{
let mut repo_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,
};
repo_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,
};
repo_config.set_str(&format!("submodule.{name}.update"), update_str)?;
}
if let Some(active) = entry.active {
let active_str = if active { "true" } else { "false" };
repo_config.set_str(&format!("submodule.{name}.active"), active_str)?;
}
if let Some(url) = &entry.url
&& submodule.url().ok().flatten() != Some(url.as_str())
{
repo_config.set_str(&format!("submodule.{name}.url"), url)?;
}
submodule.sync()?;
}
}
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 (Ok(name), Ok(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::Rebase | SerializableUpdate::Merge => {
eprintln!(
"Warning: git2 doesn't support rebase/merge update strategies, using checkout"
);
}
SerializableUpdate::None => return Ok(()),
SerializableUpdate::Checkout | 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()
.ok()
.flatten()
.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 Ok(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
}
}