mod cli;
mod config;
mod error;
mod git;
mod sync;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser};
use clap_complete::generate;
use colored::Colorize;
use cli::{Cli, Commands};
use config::{GemoteConfig, RemoteConfig};
fn effective_recursive(cli: Option<bool>, cfg: bool) -> bool {
cli.unwrap_or(cfg)
}
fn main() -> Result<()> {
let cli = Cli::parse();
if let Commands::Completions { shell } = cli.command {
generate(shell, &mut Cli::command(), "gemote", &mut std::io::stdout());
return Ok(());
}
let repo = git::open_repo(cli.repo.as_deref()).context("Could not open git repository")?;
let repo_root = repo
.workdir()
.context("Repository has no working directory (bare repo)")?
.to_path_buf();
match cli.command {
Commands::Sync { dry_run, recursive } => {
cmd_sync(&repo, &repo_root, cli.config, dry_run, recursive.resolve())
}
Commands::Save { force, recursive } => {
cmd_save(&repo, &repo_root, cli.config, force, recursive.resolve())
}
Commands::Completions { .. } => unreachable!(),
}
}
fn warn_submodules_skipped(cfg: &GemoteConfig) {
let count = cfg.submodules.len();
if count == 0 {
return;
}
eprintln!(
"{} .gemote defines {} submodule section(s) but recursion is disabled.",
"warning:".yellow().bold(),
count
);
eprintln!(" Pass -r, or add `recursive = true` under [settings] in .gemote.");
}
fn cmd_sync(
repo: &git2::Repository,
repo_root: &Path,
config_path: Option<PathBuf>,
dry_run: bool,
cli_recursive: Option<bool>,
) -> Result<()> {
let config_file = config_path.unwrap_or_else(|| repo_root.join(".gemote"));
let cfg = config::load_config(&config_file)
.with_context(|| format!("Failed to load config from {}", config_file.display()))?;
let recursive = effective_recursive(cli_recursive, cfg.settings.recursive);
if !recursive {
warn_submodules_skipped(&cfg);
}
sync_one_repo(repo, &cfg, None, dry_run)?;
if recursive {
let sub_repos =
git::collect_all_repos(repo, repo_root).context("Failed to discover sub-repos")?;
let discovered_paths: std::collections::BTreeSet<String> =
sub_repos.iter().map(|s| s.path.clone()).collect();
for path in cfg.submodules.keys() {
if !discovered_paths.contains(path) {
eprintln!(
"{} config has submodule section '{}' but no matching repo found",
"warning:".yellow().bold(),
path
);
}
}
for sub in &sub_repos {
if let Some(sub_cfg) = cfg.submodules.get(&sub.path) {
println!("\n{} {}", "Submodule:".cyan().bold(), sub.path.bold());
sync_one_repo(&sub.repo, sub_cfg, Some(&sub.path), dry_run)?;
if !sub_cfg.submodules.is_empty()
&& let Some(sub_root) = sub.repo.workdir()
{
sync_submodules_recursive(&sub.repo, sub_root, sub_cfg, &sub.path, dry_run)?;
}
} else {
eprintln!(
"{} discovered repo '{}' has no config section (skipping)",
"warning:".yellow().bold(),
sub.path
);
}
}
}
Ok(())
}
fn sync_submodules_recursive(
parent_repo: &git2::Repository,
parent_root: &Path,
parent_cfg: &GemoteConfig,
parent_path: &str,
dry_run: bool,
) -> Result<()> {
let sub_repos =
git::collect_all_repos(parent_repo, parent_root).context("Failed to discover sub-repos")?;
for sub in &sub_repos {
let full_path = format!("{}/{}", parent_path, sub.path);
if let Some(sub_cfg) = parent_cfg.submodules.get(&sub.path) {
println!("\n{} {}", "Submodule:".cyan().bold(), full_path.bold());
sync_one_repo(&sub.repo, sub_cfg, Some(&full_path), dry_run)?;
if !sub_cfg.submodules.is_empty()
&& let Some(sub_root) = sub.repo.workdir()
{
sync_submodules_recursive(&sub.repo, sub_root, sub_cfg, &full_path, dry_run)?;
}
} else {
eprintln!(
"{} discovered repo '{}' has no config section (skipping)",
"warning:".yellow().bold(),
full_path
);
}
}
Ok(())
}
fn sync_one_repo(
repo: &git2::Repository,
cfg: &GemoteConfig,
label: Option<&str>,
dry_run: bool,
) -> Result<()> {
let local = git::list_remotes(repo).context("Failed to list local remotes")?;
let actions = sync::compute_diff(cfg, &local);
if actions.is_empty() {
let prefix = label.map(|l| format!("[{}] ", l)).unwrap_or_default();
println!(
"{}{}",
prefix,
"Already in sync. No changes needed.".green()
);
return Ok(());
}
for action in &actions {
println!(" {action}");
}
if dry_run {
println!("{}", "(dry run — no changes applied)".dimmed());
} else {
sync::apply_actions(repo, &actions).context("Failed to apply sync actions")?;
let prefix = label.map(|l| format!("[{}] ", l)).unwrap_or_default();
println!("{}{}", prefix, "Sync complete.".green().bold());
}
Ok(())
}
fn cmd_save(
repo: &git2::Repository,
repo_root: &Path,
config_path: Option<PathBuf>,
force: bool,
cli_recursive: Option<bool>,
) -> Result<()> {
let config_file = config_path.unwrap_or_else(|| repo_root.join(".gemote"));
if config_file.exists() && !force {
anyhow::bail!(
"{} already exists. Use --force to replace it.",
config_file.display()
);
}
let prior = if config_file.exists() {
Some(config::load_config(&config_file).with_context(|| {
format!(
"Failed to load existing config at {}",
config_file.display()
)
})?)
} else {
None
};
let prior_settings = prior.as_ref().map(|c| c.settings.clone());
let cfg_recursive = prior_settings
.as_ref()
.map(|s| s.recursive)
.unwrap_or(false);
let recursive = effective_recursive(cli_recursive, cfg_recursive);
if !recursive && let Some(prior_cfg) = prior.as_ref() {
warn_submodules_skipped(prior_cfg);
}
let mut cfg = save_one_repo(repo)?;
if let Some(s) = prior_settings {
cfg.settings = s;
}
if recursive {
let sub_repos =
git::collect_all_repos(repo, repo_root).context("Failed to discover sub-repos")?;
for sub in &sub_repos {
println!("{} {}", "Submodule:".cyan().bold(), sub.path.bold());
let mut sub_cfg = save_one_repo(&sub.repo)?;
if let Some(sub_root) = sub.repo.workdir() {
save_submodules_recursive(&sub.repo, sub_root, &mut sub_cfg)?;
}
cfg.submodules.insert(sub.path.clone(), sub_cfg);
}
}
let content = config::serialize_config(&cfg).context("Failed to serialize config")?;
std::fs::write(&config_file, &content)
.with_context(|| format!("Failed to write {}", config_file.display()))?;
println!(
"{} {}",
"Saved remotes to".green(),
config_file.display().to_string().bold()
);
Ok(())
}
fn save_submodules_recursive(
parent_repo: &git2::Repository,
parent_root: &Path,
parent_cfg: &mut GemoteConfig,
) -> Result<()> {
let sub_repos =
git::collect_all_repos(parent_repo, parent_root).context("Failed to discover sub-repos")?;
for sub in &sub_repos {
let mut sub_cfg = save_one_repo(&sub.repo)?;
if let Some(sub_root) = sub.repo.workdir() {
save_submodules_recursive(&sub.repo, sub_root, &mut sub_cfg)?;
}
parent_cfg.submodules.insert(sub.path.clone(), sub_cfg);
}
Ok(())
}
fn save_one_repo(repo: &git2::Repository) -> Result<GemoteConfig> {
let local = git::list_remotes(repo).context("Failed to list local remotes")?;
let mut cfg = GemoteConfig::default();
for (name, info) in local {
cfg.remotes.insert(
name,
RemoteConfig {
url: info.url,
push_url: info.push_url,
},
);
}
Ok(cfg)
}
#[cfg(test)]
mod tests {
use super::effective_recursive;
#[test]
fn cli_override_beats_cfg() {
assert!(effective_recursive(Some(true), false));
assert!(!effective_recursive(Some(false), true));
}
#[test]
fn cli_none_falls_back_to_cfg() {
assert!(effective_recursive(None, true));
assert!(!effective_recursive(None, false));
}
}