use crate::fleet::config::GitModules;
use crate::fleet::error::{Result, SuperGitError};
use crate::fleet::git::GitOps;
use crate::fleet::output::Formatter;
use crate::git_backend;
use std::path::{Path, PathBuf};
#[derive(Debug)]
struct Action {
repo_name: String,
description: String,
succeeded: bool,
}
struct AuditLog {
actions: Vec<Action>,
}
impl AuditLog {
fn new() -> Self {
Self {
actions: Vec::new(),
}
}
fn record(&mut self, repo_name: &str, description: &str, succeeded: bool) {
self.actions.push(Action {
repo_name: repo_name.to_string(),
description: description.to_string(),
succeeded,
});
}
fn print_and_fail(&self, final_error: &str) -> SuperGitError {
eprintln!();
Formatter::print_error("Merge-back failed. Chronological action log:");
eprintln!();
for (i, action) in self.actions.iter().enumerate() {
let symbol = if action.succeeded { "✓" } else { "✗" };
let color_symbol = if action.succeeded {
format!("\x1b[32m{}\x1b[0m", symbol) } else {
format!("\x1b[31m{}\x1b[0m", symbol) };
eprintln!(
" {:>3}. {} [{}] {}",
i + 1,
color_symbol,
action.repo_name,
action.description,
);
}
eprintln!();
SuperGitError::Other(final_error.to_string())
}
}
struct RepoInfo {
name: String,
path: PathBuf,
current_branch: String,
main_branch: String,
}
pub fn execute<P: AsRef<Path>>(
repo_root: P,
message: &str,
dry_run: bool,
verbose: bool,
) -> Result<()> {
let repo_root = repo_root.as_ref();
let gitmodules = GitModules::parse(repo_root)?;
Formatter::print_info(&format!(
"Merge-back: superproject + {} submodule(s)",
gitmodules.submodules.len()
));
Formatter::print_info("Running pre-flight checks...");
let mut repos: Vec<RepoInfo> = Vec::new();
{
let super_repo = GitOps::open_repo(repo_root)?;
if GitOps::is_detached_head(&super_repo)? {
return Err(SuperGitError::Other(
"Superproject is in detached HEAD state".to_string(),
));
}
let current_branch = GitOps::current_branch(&super_repo)?;
let main_branch = GitOps::get_default_branch(&super_repo)?;
if current_branch == main_branch {
return Err(SuperGitError::Other(format!(
"Superproject is already on '{}'. Merge-back requires being on a feature branch.",
main_branch
)));
}
if !GitOps::branch_exists(&super_repo, &main_branch)? {
return Err(SuperGitError::Other(format!(
"Superproject has no '{}' branch",
main_branch
)));
}
if verbose {
Formatter::print_info(&format!(
"Superproject: on '{}', will merge into '{}'",
current_branch, main_branch
));
}
repos.push(RepoInfo {
name: "[SUPERPROJECT]".to_string(),
path: repo_root.to_path_buf(),
current_branch,
main_branch,
});
}
for submodule in &gitmodules.submodules {
let submodule_path = repo_root.join(&submodule.path);
if !submodule_path.exists() {
return Err(SuperGitError::Other(format!(
"Submodule '{}' is not initialized. All submodules must be initialized for merge-back.",
submodule.name
)));
}
let sub_repo = GitOps::open_repo(&submodule_path)?;
if GitOps::is_detached_head(&sub_repo)? {
return Err(SuperGitError::Other(format!(
"Submodule '{}' is in detached HEAD state",
submodule.name
)));
}
let current_branch = GitOps::current_branch(&sub_repo)?;
let main_branch = GitOps::get_default_branch(&sub_repo)?;
if current_branch == main_branch {
return Err(SuperGitError::Other(format!(
"Submodule '{}' is already on '{}'. Merge-back requires all repos to be on a feature branch.",
submodule.name, main_branch
)));
}
if !GitOps::branch_exists(&sub_repo, &main_branch)? {
return Err(SuperGitError::Other(format!(
"Submodule '{}' has no '{}' branch",
submodule.name, main_branch
)));
}
if verbose {
Formatter::print_info(&format!(
"'{}': on '{}', will merge into '{}'",
submodule.name, current_branch, main_branch
));
}
repos.push(RepoInfo {
name: submodule.name.clone(),
path: submodule_path,
current_branch,
main_branch,
});
}
Formatter::print_success(&format!("Pre-flight passed for {} repo(s)", repos.len()));
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
println!();
Formatter::print_info("Merge-back plan:");
for repo in &repos {
println!(
" {} [{}] '{}' → checkout '{}' → merge --ff-only '{}' → push",
"→".cyan(),
repo.name,
repo.current_branch,
repo.main_branch,
repo.current_branch,
);
}
return Ok(());
}
let mut log = AuditLog::new();
for repo_info in &repos {
let repo = GitOps::open_repo(&repo_info.path).map_err(|e| {
log.record(&repo_info.name, &format!("Open repo: {}", e), false);
log.print_and_fail(&format!("Failed to open '{}'", repo_info.name))
})?;
let is_clean = GitOps::is_clean(&repo).map_err(|e| {
log.record(&repo_info.name, &format!("Check clean: {}", e), false);
log.print_and_fail(&format!("Failed to check status of '{}'", repo_info.name))
})?;
if !is_clean {
git_backend::stage_all(&repo).map_err(|e| {
log.record(&repo_info.name, &format!("Stage all changes: {}", e), false);
log.print_and_fail(&format!("Failed to stage in '{}'", repo_info.name))
})?;
log.record(&repo_info.name, "Staged all changes", true);
match git_backend::commit_all(&repo, message).map_err(|e| {
log.record(&repo_info.name, &format!("Commit: {}", e), false);
log.print_and_fail(&format!("Failed to commit in '{}'", repo_info.name))
})? {
Some(_) => log.record(
&repo_info.name,
&format!("Committed on '{}': {}", repo_info.current_branch, message),
true,
),
None => log.record(
&repo_info.name,
"Nothing to commit (clean after staging)",
true,
),
}
} else {
log.record(&repo_info.name, "Already clean, no commit needed", true);
}
}
for repo_info in &repos {
let repo = GitOps::open_repo(&repo_info.path).map_err(|e| {
log.record(&repo_info.name, &format!("Open repo for feature push: {}", e), false);
log.print_and_fail(&format!("Failed to open '{}'", repo_info.name))
})?;
git_backend::push_current_branch(&repo, Some("origin"), false, false).map_err(|e| {
log.record(
&repo_info.name,
&format!("Push '{}' to origin: {}", repo_info.current_branch, e),
false,
);
log.print_and_fail(&format!("Failed to push '{}'", repo_info.name))
})?;
log.record(
&repo_info.name,
&format!("Pushed '{}' to origin", repo_info.current_branch),
true,
);
}
for repo_info in &repos {
let repo = GitOps::open_repo(&repo_info.path).map_err(|e| {
log.record(&repo_info.name, &format!("Open repo for checkout: {}", e), false);
log.print_and_fail(&format!("Failed to open '{}'", repo_info.name))
})?;
git_backend::checkout_branch(&repo, &repo_info.main_branch).map_err(|e| {
log.record(
&repo_info.name,
&format!("Checkout '{}': {}", repo_info.main_branch, e),
false,
);
log.print_and_fail(&format!(
"Failed to checkout '{}' in '{}'",
repo_info.main_branch, repo_info.name
))
})?;
log.record(
&repo_info.name,
&format!("Checked out '{}'", repo_info.main_branch),
true,
);
}
for repo_info in &repos {
let repo = GitOps::open_repo(&repo_info.path).map_err(|e| {
log.record(&repo_info.name, &format!("Open repo for merge: {}", e), false);
log.print_and_fail(&format!("Failed to open '{}'", repo_info.name))
})?;
if let Err(e) = git_backend::merge_ff(&repo, &repo_info.current_branch) {
log.record(
&repo_info.name,
&format!(
"Merge --ff-only '{}' into '{}': {}",
repo_info.current_branch, repo_info.main_branch, e
),
false,
);
return Err(log.print_and_fail(&format!(
"Cannot fast-forward merge '{}' into '{}' in '{}'. \
The branch may have diverged from '{}'. \
Rebase onto '{}' first.",
repo_info.current_branch,
repo_info.main_branch,
repo_info.name,
repo_info.main_branch,
repo_info.main_branch,
)));
}
log.record(
&repo_info.name,
&format!(
"Merged '{}' into '{}' (fast-forward)",
repo_info.current_branch, repo_info.main_branch
),
true,
);
}
for repo_info in &repos {
let repo = GitOps::open_repo(&repo_info.path).map_err(|e| {
log.record(&repo_info.name, &format!("Open repo for push: {}", e), false);
log.print_and_fail(&format!("Failed to open '{}'", repo_info.name))
})?;
git_backend::push_current_branch(&repo, Some("origin"), false, false).map_err(|e| {
log.record(
&repo_info.name,
&format!("Push '{}' to origin: {}", repo_info.main_branch, e),
false,
);
log.print_and_fail(&format!(
"Failed to push '{}' in '{}'",
repo_info.main_branch, repo_info.name
))
})?;
log.record(
&repo_info.name,
&format!("Pushed '{}' to origin", repo_info.main_branch),
true,
);
}
println!();
Formatter::print_success("Merge-back complete!");
println!();
for repo_info in &repos {
Formatter::print_success(&format!(
"[{}] '{}' merged into '{}' and pushed",
repo_info.name, repo_info.current_branch, repo_info.main_branch
));
}
Ok(())
}
use colored::Colorize;