use crate::core::config::{
ERROR_MESSAGE_MAX_LENGTH, ERROR_MESSAGE_TRUNCATE_LENGTH, PATH_DISPLAY_WIDTH,
TIMEOUT_SECONDS_DISPLAY,
};
use crate::git::Status;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
#[derive(Debug)]
pub struct SyncStatistics {
pub synced_repos: AtomicU64,
pub total_commits_pushed: AtomicU64,
pub skipped_repos: AtomicU64,
pub error_repos: AtomicU64,
pub uncommitted_count: AtomicU64,
pub failed_repos: Mutex<Vec<(String, String, String)>>, pub no_upstream_repos: Mutex<Vec<(String, String)>>, pub no_remote_repos: Mutex<Vec<(String, String)>>, pub uncommitted_repos: Mutex<Vec<(String, String)>>, }
impl SyncStatistics {
pub fn new() -> Self {
Self {
synced_repos: AtomicU64::new(0),
total_commits_pushed: AtomicU64::new(0),
skipped_repos: AtomicU64::new(0),
error_repos: AtomicU64::new(0),
uncommitted_count: AtomicU64::new(0),
failed_repos: Mutex::new(Vec::new()),
no_upstream_repos: Mutex::new(Vec::new()),
no_remote_repos: Mutex::new(Vec::new()),
uncommitted_repos: Mutex::new(Vec::new()),
}
}
pub fn update(
&self,
repo_name: &str,
repo_path: &str,
status: &Status,
message: &str,
has_uncommitted: bool,
) {
match status {
Status::Pushed => {
self.synced_repos.fetch_add(1, Ordering::Relaxed);
if let Ok(commits) = message
.split_whitespace()
.next()
.unwrap_or("0")
.parse::<u64>()
{
self.total_commits_pushed.fetch_add(commits, Ordering::Relaxed);
}
}
Status::Pulled => {
self.synced_repos.fetch_add(1, Ordering::Relaxed);
if let Ok(commits) = message
.split_whitespace()
.next()
.unwrap_or("0")
.parse::<u64>()
{
self.total_commits_pushed.fetch_add(commits, Ordering::Relaxed);
}
}
Status::Synced
| Status::ConfigSynced
| Status::ConfigUpdated
| Status::Staged
| Status::Unstaged
| Status::Committed => {
self.synced_repos.fetch_add(1, Ordering::Relaxed);
}
Status::Skip | Status::ConfigSkipped | Status::NoChanges => {
self.skipped_repos.fetch_add(1, Ordering::Relaxed);
}
Status::NoUpstream => {
self.skipped_repos.fetch_add(1, Ordering::Relaxed);
self.no_upstream_repos
.lock()
.unwrap()
.push((repo_name.to_string(), repo_path.to_string()));
}
Status::NoRemote => {
self.skipped_repos.fetch_add(1, Ordering::Relaxed);
self.no_remote_repos
.lock()
.unwrap()
.push((repo_name.to_string(), repo_path.to_string()));
}
Status::Error | Status::ConfigError | Status::StagingError | Status::CommitError | Status::PullError => {
self.error_repos.fetch_add(1, Ordering::Relaxed);
self.failed_repos
.lock()
.unwrap()
.push((
repo_name.to_string(),
repo_path.to_string(),
message.to_string(),
));
}
}
if has_uncommitted
&& !matches!(
status,
Status::Error | Status::ConfigError | Status::StagingError | Status::CommitError | Status::PullError
)
{
let mut uncommitted = self.uncommitted_repos.lock().unwrap();
if !uncommitted.iter().any(|(name, _)| name == repo_name) {
self.uncommitted_count.fetch_add(1, Ordering::Relaxed);
uncommitted.push((repo_name.to_string(), repo_path.to_string()));
}
}
}
pub fn generate_summary(&self, _total_repos: usize, duration: Duration) -> String {
let duration_secs = duration.as_secs_f64();
let synced = self.synced_repos.load(Ordering::Relaxed);
let pushed = self.total_commits_pushed.load(Ordering::Relaxed);
let errors = self.error_repos.load(Ordering::Relaxed);
let mut summary = String::new();
if errors > 0 {
summary.push_str(&format!(
"✅ Completed in {:.1}s • {} synced • {} pushed • {} failed",
duration_secs, synced, pushed, errors
));
} else {
summary.push_str(&format!(
"✅ Completed in {:.1}s • {} synced • {} pushed",
duration_secs, synced, pushed
));
}
summary
}
pub fn generate_detailed_summary(&self, show_changes: bool) -> String {
let mut lines = Vec::new();
let failed_repos = self.failed_repos.lock().unwrap();
let no_upstream_repos = self.no_upstream_repos.lock().unwrap();
let no_remote_repos = self.no_remote_repos.lock().unwrap();
let uncommitted_repos = self.uncommitted_repos.lock().unwrap();
if !failed_repos.is_empty() {
lines.push(format!("🔴 FAILED REPOS ({})", failed_repos.len()));
for (i, (repo_name, repo_path, error)) in failed_repos.iter().enumerate() {
let tree_char = if i == failed_repos.len() - 1 {
"└─"
} else {
"├─"
};
let short_path = crate::utils::shorten_path(repo_path, PATH_DISPLAY_WIDTH);
lines.push(format!(
" {} {:20} {:30} # {}",
tree_char, repo_name, short_path, error
));
}
lines.push(String::new()); }
if !no_upstream_repos.is_empty() {
lines.push(format!(
"🟡 NEEDS UPSTREAM ({})",
no_upstream_repos.len()
));
for (i, (repo_name, repo_path)) in no_upstream_repos.iter().enumerate() {
let tree_char = if i == no_upstream_repos.len() - 1 {
"└─"
} else {
"├─"
};
let short_path = crate::utils::shorten_path(repo_path, PATH_DISPLAY_WIDTH);
lines.push(format!(
" {} {:20} {:30} # git push -u origin <branch>",
tree_char, repo_name, short_path
));
}
lines.push(String::new()); }
if !uncommitted_repos.is_empty() {
lines.push(format!(
"⚠️ UNCOMMITTED CHANGES ({})",
uncommitted_repos.len()
));
for (i, (repo_name, repo_path)) in uncommitted_repos.iter().enumerate() {
let tree_char = if i == uncommitted_repos.len() - 1 {
"└─"
} else {
"├─"
};
let short_path = crate::utils::shorten_path(repo_path, PATH_DISPLAY_WIDTH);
if show_changes {
lines.push(format!(" {} {:20} {}", tree_char, repo_name, short_path));
if let Ok(changes) = get_repo_changes(repo_path) {
if !changes.is_empty() {
let is_last_repo = i == uncommitted_repos.len() - 1;
for (file_idx, change) in changes.iter().enumerate() {
let is_last_file = file_idx == changes.len() - 1;
let prefix = if is_last_repo {
if is_last_file { " └─" } else { " ├─" }
} else if is_last_file { " │ └─" } else { " │ ├─" };
lines.push(format!("{} {}", prefix, change));
}
}
}
} else {
lines.push(format!(" {} {:20} {}", tree_char, repo_name, short_path));
}
}
lines.push(String::new()); }
if !no_remote_repos.is_empty() {
lines.push(format!(
"🔧 MISSING REMOTES ({})",
no_remote_repos.len()
));
for (i, (repo_name, repo_path)) in no_remote_repos.iter().enumerate() {
let tree_char = if i == no_remote_repos.len() - 1 {
"└─"
} else {
"├─"
};
let short_path = crate::utils::shorten_path(repo_path, PATH_DISPLAY_WIDTH);
lines.push(format!(" {} {:20} {}", tree_char, repo_name, short_path));
}
}
if lines.last() == Some(&String::new()) {
lines.pop();
}
lines.join("\n")
}
}
pub(crate) fn clean_error_message(error: &str) -> String {
let cleaned = error
.replace('\n', " ")
.replace('\r', "")
.replace('\t', " ");
let cleaned = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
let message = if cleaned.contains("repository moved") {
if cleaned.contains("email privacy") {
"repo moved + email privacy".to_string()
} else {
"repo moved".to_string()
}
} else if cleaned.contains("email privacy") {
"email privacy restriction".to_string()
} else if cleaned.contains("timed out") {
if cleaned.contains(&TIMEOUT_SECONDS_DISPLAY.to_string()) {
format!("timeout ({}s)", TIMEOUT_SECONDS_DISPLAY)
} else {
"timeout".to_string()
}
} else if cleaned.contains("authentication") || cleaned.contains("Permission denied") {
"authentication failed".to_string()
} else if cleaned.contains("conflict") || cleaned.contains("diverged") {
"merge conflict".to_string()
} else if cleaned.contains("Connection") || cleaned.contains("network") {
"network error".to_string()
} else {
if cleaned.len() > ERROR_MESSAGE_MAX_LENGTH {
format!("{}...", &cleaned[..ERROR_MESSAGE_TRUNCATE_LENGTH])
} else {
cleaned
}
};
message
}
fn get_repo_changes(repo_path: &str) -> Result<Vec<String>, std::io::Error> {
use std::path::Path;
use std::process::Command;
let path = Path::new(repo_path);
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(path)
.output()?;
if !output.status.success() {
return Ok(Vec::new());
}
let status_output = String::from_utf8_lossy(&output.stdout);
let mut changes = Vec::new();
const MAX_FILES: usize = 10;
for (i, line) in status_output.lines().enumerate() {
if i >= MAX_FILES {
let remaining = status_output.lines().count() - MAX_FILES;
changes.push(format!("... and {} more", remaining));
break;
}
if !line.is_empty() {
changes.push(line.to_string());
}
}
Ok(changes)
}