use crate::fleet::error::{Result, SuperGitError};
use crate::fleet::git::{GitOps, SubmoduleNode, SubmoduleOps, SubmoduleTree};
use crate::fleet::output::Formatter;
use crate::git_backend;
use crate::prelude::*;
use crate::workspace_state::WorkspaceState;
use std::collections::HashMap;
#[allow(clippy::too_many_arguments)]
pub fn execute<P: AsRef<Path>>(
repo_root: P,
message: &str,
push: bool,
stage_all: bool,
exclude: &[String],
include: &[String],
force_all: bool,
dry_run: bool,
verbose: bool,
) -> Result<()> {
let tree = SubmoduleTree::discover(&repo_root)?;
Formatter::print_info(&format!(
"Committing changes in superproject + {} submodule(s)",
tree.len()
));
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
}
let mut successful = 0;
let mut failed = 0;
let mut skipped = 0;
let mut pending_gitlinks: HashMap<PathBuf, Vec<String>> = HashMap::new();
for node in tree.bottom_up() {
if verbose {
Formatter::print_submodule_header(node.label());
}
if !selected(node, include, exclude, verbose)? {
skipped += 1;
continue;
}
if !node.initialized {
Formatter::print_warning(&format!(
"Submodule '{}' not initialized, skipping",
node.label()
));
skipped += 1;
continue;
}
let gitlink = node.rel_path.to_str().ok_or_else(|| {
SuperGitError::Other(format!(
"Submodule path '{}' is not valid UTF-8 and cannot be staged in its parent",
node.rel_path.display()
))
})?;
let repo = match GitOps::open_repo(&node.abs_path) {
Ok(r) => r,
Err(e) => {
Formatter::print_error(&format!("Failed to open '{}': {}", node.label(), e));
failed += 1;
continue;
}
};
let child_links = pending_gitlinks.remove(&node.abs_path).unwrap_or_default();
if !child_links.is_empty() && !dry_run {
if let Err(e) = git_backend::stage_paths(&repo, &child_links) {
Formatter::print_error(&format!(
"Failed to record nested submodule commits in '{}': {}",
node.label(),
e
));
failed += 1;
continue;
}
}
let is_clean = match GitOps::is_clean(&repo) {
Ok(c) => c,
Err(e) => {
Formatter::print_error(&format!(
"Failed to check status of '{}': {}",
node.label(),
e
));
failed += 1;
continue;
}
};
if is_clean && child_links.is_empty() && !force_all {
if verbose {
Formatter::print_info(&format!("No changes in '{}'", node.label()));
}
skipped += 1;
continue;
}
if dry_run {
if stage_all {
Formatter::print_info("Would stage all changes (git add -A)");
}
if !child_links.is_empty() {
Formatter::print_info(&format!(
"Would record {} nested submodule commit(s)",
child_links.len()
));
}
Formatter::print_info("Would commit changes");
successful += 1;
pending_gitlinks
.entry(node.parent_path.clone())
.or_default()
.push(gitlink.to_string());
continue;
}
if stage_all {
if let Err(e) = git_backend::stage_all(&repo) {
Formatter::print_error(&format!("Failed to stage in '{}': {}", node.label(), e));
failed += 1;
continue;
}
}
match SubmoduleOps::commit(node, message) {
Ok(_) => {
Formatter::print_success(&format!("Committed changes in '{}'", node.label()));
successful += 1;
pending_gitlinks
.entry(node.parent_path.clone())
.or_default()
.push(gitlink.to_string());
if push {
match SubmoduleOps::push(node, false) {
Ok(_) => {
Formatter::print_success(&format!(
"Pushed changes in '{}'",
node.label()
));
}
Err(e) => {
Formatter::print_error(&format!(
"Failed to push in '{}': {}",
node.label(),
e
));
failed += 1;
}
}
}
}
Err(e) => {
Formatter::print_error(&format!("Failed to commit in '{}': {}", node.label(), e));
failed += 1;
}
}
}
if dry_run {
if pending_gitlinks.contains_key(repo_root.as_ref()) {
Formatter::print_info("Would update superproject to reference new commits");
}
return Ok(());
}
let root_links = pending_gitlinks
.remove(repo_root.as_ref())
.unwrap_or_default();
if !pending_gitlinks.is_empty() {
let orphans: Vec<String> = pending_gitlinks
.keys()
.map(|path| path.display().to_string())
.collect();
return Err(SuperGitError::Other(format!(
"Internal error: committed submodules whose parent was never visited: {}. \
The tree would be left with unrecorded gitlinks.",
orphans.join(", ")
)));
}
Formatter::print_summary(successful, failed, skipped);
if !root_links.is_empty() {
Formatter::print_info("Updating superproject...");
let repo = git_backend::open_repo(repo_root.as_ref())?;
git_backend::stage_paths(&repo, &root_links)?;
if stage_all {
git_backend::stage_all(&repo)?;
}
match git_backend::commit_all(&repo, &format!("Update submodules: {}", message)) {
Ok(Some(_)) => {
Formatter::print_success("Committed in superproject");
if push {
match git_backend::push_current_branch(&repo, Some("origin"), true, false) {
Ok(_) => Formatter::print_success("Pushed superproject"),
Err(err) => {
Formatter::print_error(&format!(
"Failed to push superproject: {}",
err
));
failed += 1;
}
}
}
}
Ok(None) => {
if verbose {
Formatter::print_info("Superproject already references these commits");
}
}
Err(err) => {
Formatter::print_error(&format!("Failed to commit in superproject: {}", err));
failed += 1;
}
}
}
if failed > 0 {
return Err(SuperGitError::Other(format!(
"{} submodule(s) failed to commit",
failed
)));
}
Ok(())
}
fn selected(
node: &SubmoduleNode,
include: &[String],
exclude: &[String],
verbose: bool,
) -> Result<bool> {
if include.is_empty() && exclude.is_empty() {
return Ok(true);
}
let alias = WorkspaceState::load(&node.abs_path)
.ok()
.and_then(|state| state.wstemplate_entry().map(|entry| entry.alias.clone()));
let matches = |candidate: &String| {
alias.as_ref() == Some(candidate)
|| &node.name == candidate
|| &node.display_path == candidate
};
let label = alias.as_deref().unwrap_or(&node.display_path);
if !include.is_empty() && !include.iter().any(matches) {
if verbose {
Formatter::print_info(&format!("Skipping '{}' (not in --include list)", label));
}
return Ok(false);
}
if exclude.iter().any(matches) {
if verbose {
Formatter::print_info(&format!("Skipping '{}' (excluded)", label));
}
return Ok(false);
}
Ok(true)
}