diff --git a/README.md b/README.md index ce42b37..43eeaf9 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,22 @@ Or use the interactive wizard, which can create or update the same config file: git-sync config wizard ``` -The wizard asks for the provider first, suggests a site name from that provider, stores the PAT directly in the config file, and validates the PAT against the provider before saving the site. +The wizard asks for profile or organization URLs, reuses existing credentials when it can, asks for a PAT only when needed, and then shows the sync group before asking whether to add another group. + +Example wizard flow: + +1. Enter `https://github.com/alice`. +2. Paste a PAT if no existing GitHub credential can access that namespace. +3. Enter `https://git.wonder.land/alice`. +4. Pick the provider if the instance cannot be detected. +5. Paste a PAT if needed. +6. Optionally add a third endpoint for 3-way sync. + +PAT quick setup: + +- GitHub: open `https://github.com/settings/tokens`, create a classic PAT with `repo` permissions, then copy the token. +- GitLab: open `/-/user_settings/personal_access_tokens?name=git-sync&scopes=api`, create the token, then copy it. +- Gitea: open `/user/settings/applications`, create a token with repository access, then copy it. Add sites. Prefer `--token-env` so PATs do not live in shell history or the config file. diff --git a/src/git.rs b/src/git.rs index 96eb0a6..f6be6d0 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,9 +1,12 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; +use console::style; #[derive(Clone, Debug)] pub struct RemoteSpec { @@ -17,6 +20,7 @@ pub struct BranchDecision { pub branch: String, pub sha: String, pub source_remotes: Vec, + pub target_remotes: Vec, } #[derive(Clone, Debug)] @@ -30,6 +34,7 @@ pub struct TagDecision { pub tag: String, pub sha: String, pub source_remotes: Vec, + pub target_remotes: Vec, } #[derive(Clone, Debug)] @@ -53,7 +58,11 @@ impl GitMirror { pub fn open(path: PathBuf, redactor: Redactor, dry_run: bool) -> Result { if !path.exists() { if dry_run { - println!("dry-run: git init --bare {}", path.display()); + println!( + " {} git init --bare {}", + style("dry-run").yellow().bold(), + style(path.display()).dim() + ); return Ok(Self { path, redactor, @@ -85,7 +94,11 @@ impl GitMirror { pub fn fetch_remote(&self, remote: &RemoteSpec) -> Result<()> { let branch_refspec = format!("+refs/heads/*:refs/remotes/{}/*", remote.name); let tag_refspec = format!("+refs/tags/*:refs/remote-tags/{}/*", remote.name); - println!("fetching {}", remote.display); + println!( + " {} {}", + style("fetch").cyan().bold(), + style(&remote.display).dim() + ); self.run(["fetch", "--prune", &remote.name, &branch_refspec])?; self.run(["fetch", "--prune", &remote.name, &tag_refspec]) } @@ -107,6 +120,10 @@ impl GitMirror { let mut decisions = Vec::new(); let mut conflicts = Vec::new(); + let all_remote_names = remotes + .iter() + .map(|remote| remote.name.clone()) + .collect::>(); for (branch, tips) in by_branch { let unique = tips @@ -114,34 +131,46 @@ impl GitMirror { .map(|(_, sha)| sha.clone()) .collect::>(); if unique.len() == 1 { + let source_remotes = tips + .into_iter() + .map(|(remote, _)| remote) + .collect::>(); + let target_remotes = missing_remotes(&all_remote_names, &source_remotes); decisions.push(BranchDecision { branch, sha: unique.into_iter().next().unwrap(), - source_remotes: tips.into_iter().map(|(remote, _)| remote).collect(), + source_remotes, + target_remotes, }); continue; } if let Some(winner) = self.fast_forward_winner(unique.iter())? { let source_remotes = tips - .into_iter() - .filter_map(|(remote, sha)| (sha == winner).then_some(remote)) - .collect(); + .iter() + .filter_map(|(remote, sha)| (sha == &winner).then_some(remote)) + .cloned() + .collect::>(); + let target_remotes = missing_remotes(&all_remote_names, &source_remotes); decisions.push(BranchDecision { branch, sha: winner, source_remotes, + target_remotes, }); } else if allow_force { let winner = self.newest_commit(unique.iter())?; let source_remotes = tips - .into_iter() - .filter_map(|(remote, sha)| (sha == winner).then_some(remote)) - .collect(); + .iter() + .filter_map(|(remote, sha)| (sha == &winner).then_some(remote)) + .cloned() + .collect::>(); + let target_remotes = missing_remotes(&all_remote_names, &source_remotes); decisions.push(BranchDecision { branch, sha: winner, source_remotes, + target_remotes, }); } else { conflicts.push(BranchConflict { branch, tips }); @@ -167,6 +196,10 @@ impl GitMirror { let mut decisions = Vec::new(); let mut conflicts = Vec::new(); + let all_remote_names = remotes + .iter() + .map(|remote| remote.name.clone()) + .collect::>(); for (tag, tips) in by_tag { let unique = tips @@ -174,10 +207,16 @@ impl GitMirror { .map(|(_, sha)| sha.clone()) .collect::>(); if unique.len() == 1 { + let source_remotes = tips + .into_iter() + .map(|(remote, _)| remote) + .collect::>(); + let target_remotes = missing_remotes(&all_remote_names, &source_remotes); decisions.push(TagDecision { tag, sha: unique.into_iter().next().unwrap(), - source_remotes: tips.into_iter().map(|(remote, _)| remote).collect(), + source_remotes, + target_remotes, }); } else { conflicts.push(TagConflict { tag, tips }); @@ -195,12 +234,21 @@ impl GitMirror { ) -> Result<()> { for remote in remotes { for branch in branches { + if !branch.target_remotes.contains(&remote.name) { + continue; + } let refspec = if force { format!("+{}:refs/heads/{}", branch.sha, branch.branch) } else { format!("{}:refs/heads/{}", branch.sha, branch.branch) }; - println!("pushing {} to {}", branch.branch, remote.display); + println!( + " {} {} {} {}", + style("push").green().bold(), + style("branch").dim(), + style(&branch.branch).cyan(), + style(format!("-> {}", remote.display)).dim() + ); self.run(["push", &remote.name, &refspec])?; } } @@ -210,8 +258,17 @@ impl GitMirror { pub fn push_tags(&self, remotes: &[RemoteSpec], tags: &[TagDecision]) -> Result<()> { for remote in remotes { for tag in tags { + if !tag.target_remotes.contains(&remote.name) { + continue; + } let refspec = format!("{}:refs/tags/{}", tag.sha, tag.tag); - println!("pushing tag {} to {}", tag.tag, remote.display); + println!( + " {} {} {} {}", + style("push").green().bold(), + style("tag").dim(), + style(&tag.tag).cyan(), + style(format!("-> {}", remote.display)).dim() + ); self.run(["push", &remote.name, &refspec])?; } } @@ -347,15 +404,77 @@ impl GitMirror { if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } else { - bail!( - "git failed: {}", + Err(GitCommandError::new( + "git", + "", self.redactor - .redact(&String::from_utf8_lossy(&output.stderr)) - ); + .redact(&String::from_utf8_lossy(&output.stderr)), + ) + .into()) } } } +#[derive(Debug)] +pub struct GitCommandError { + program: String, + stdout: String, + stderr: String, +} + +impl GitCommandError { + fn new( + program: impl Into, + stdout: impl Into, + stderr: impl Into, + ) -> Self { + Self { + program: program.into(), + stdout: stdout.into(), + stderr: stderr.into(), + } + } + + pub fn stderr(&self) -> &str { + &self.stderr + } +} + +impl fmt::Display for GitCommandError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} failed\nstdout: {}\nstderr: {}", + self.program, self.stdout, self.stderr + ) + } +} + +impl Error for GitCommandError {} + +pub fn is_disabled_repository_error(error: &anyhow::Error) -> bool { + error + .chain() + .filter_map(|cause| cause.downcast_ref::()) + .any(|error| is_disabled_repository_stderr(error.stderr())) +} + +fn missing_remotes(all_remote_names: &[String], source_remotes: &[String]) -> Vec { + all_remote_names + .iter() + .filter(|remote| !source_remotes.contains(remote)) + .cloned() + .collect() +} + +fn is_disabled_repository_stderr(stderr: &str) -> bool { + let stderr = stderr.to_ascii_lowercase(); + stderr.contains("access to this repository has been disabled") + || stderr.contains("repository has been disabled") + || stderr.contains("disabled by github staff") + || stderr.contains("dmca takedown") +} + impl Redactor { pub fn new(secrets: Vec) -> Self { let secrets = secrets @@ -390,7 +509,12 @@ where .map(|arg| arg.as_ref().to_string()) .collect::>(); if dry_run { - println!("dry-run: {} {}", program, redactor.redact(&args.join(" "))); + println!( + " {} {} {}", + style("dry-run").yellow().bold(), + program, + style(redactor.redact(&args.join(" "))).dim() + ); return Ok(()); } @@ -407,7 +531,7 @@ where } else { let stdout = redactor.redact(&String::from_utf8_lossy(&output.stdout)); let stderr = redactor.redact(&String::from_utf8_lossy(&output.stderr)); - bail!("{program} failed\nstdout: {stdout}\nstderr: {stderr}"); + Err(GitCommandError::new(program, stdout, stderr).into()) } } @@ -448,6 +572,27 @@ mod tests { ); } + #[test] + fn detects_provider_disabled_repository_errors() { + let error: anyhow::Error = GitCommandError::new( + "git", + "", + "remote: Access to this repository has been disabled by GitHub staff.\nfatal: unable to access 'https://github.com/alice/repo.git/': The requested URL returned error: 403", + ) + .into(); + + assert!(is_disabled_repository_error(&error)); + + let generic_forbidden: anyhow::Error = GitCommandError::new( + "git", + "", + "fatal: unable to access 'https://github.com/alice/repo.git/': The requested URL returned error: 403", + ) + .into(); + + assert!(!is_disabled_repository_error(&generic_forbidden)); + } + #[test] fn branch_decisions_choose_fast_forward_tip() { let fixture = GitFixture::new(); @@ -465,9 +610,27 @@ mod tests { let main = find_branch(&decisions, "main"); assert_eq!(main.sha, newer); assert_eq!(main.source_remotes, vec!["a".to_string()]); + assert_eq!(main.target_remotes, vec!["b".to_string()]); assert_ne!(main.sha, base); } + #[test] + fn branch_decisions_do_not_target_remotes_that_already_match() { + let fixture = GitFixture::new(); + fixture.commit("base", "base", 1_700_000_000); + fixture.push_head(&fixture.remote_a, "main"); + fixture.push_head(&fixture.remote_b, "main"); + + let mirror = fixture.mirror(); + fixture.fetch_all(&mirror); + let (decisions, conflicts) = mirror.branch_decisions(&fixture.remotes(), false).unwrap(); + + assert!(conflicts.is_empty()); + let main = find_branch(&decisions, "main"); + assert_eq!(main.source_remotes, vec!["a".to_string(), "b".to_string()]); + assert!(main.target_remotes.is_empty()); + } + #[test] fn branch_decisions_report_divergent_tips_without_force() { let fixture = GitFixture::new(); @@ -514,6 +677,7 @@ mod tests { assert_eq!(main.sha, newer); assert_ne!(main.sha, older); assert_eq!(main.source_remotes, vec!["b".to_string()]); + assert_eq!(main.target_remotes, vec!["a".to_string()]); } #[test] @@ -568,7 +732,12 @@ mod tests { let (tags, conflicts) = mirror.tag_decisions(&fixture.remotes()).unwrap(); assert_eq!(find_tag(&tags, "v1").sha, base); + assert!(find_tag(&tags, "v1").target_remotes.is_empty()); assert_eq!(find_tag(&tags, "missing-on-b").sha, a_tip); + assert_eq!( + find_tag(&tags, "missing-on-b").target_remotes, + vec!["b".to_string()] + ); assert_eq!(conflicts.len(), 1); assert_eq!(conflicts[0].tag, "release"); assert!(conflicts[0].tips.iter().any(|(_, sha)| sha == &a_tip)); diff --git a/src/interactive.rs b/src/interactive.rs index 0719ce7..530236f 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -1,14 +1,17 @@ -use std::io::{self, Write}; +use std::fmt::Display; use std::path::Path; +use std::time::Duration; -use anyhow::Result; -use console::style; +use anyhow::{Context, Result}; +use console::{Term, style}; use dialoguer::{Confirm, Input, Password, Select, theme::ColorfulTheme}; +use reqwest::blocking::Client; +use url::Url; #[cfg(test)] use anyhow::bail; #[cfg(test)] -use std::io::BufRead; +use std::io::{BufRead, Write}; use crate::config::{ Config, EndpointConfig, MirrorConfig, NamespaceKind, ProviderKind, SiteConfig, TokenConfig, @@ -16,20 +19,57 @@ use crate::config::{ }; use crate::provider::ProviderClient; +#[derive(Clone, Debug)] +struct ProfileTarget { + base_url: String, + provider: ProviderKind, + namespace: String, + kind: Option, +} + +#[derive(Clone, Debug)] +struct ParsedProfileUrl { + base_url: String, + host: String, + namespace: String, +} + pub fn run_config_wizard(path: &Path) -> Result<()> { + let existing_config = path.exists(); let mut config = Config::load_or_default(path)?; let theme = ColorfulTheme::default(); println!(); println!("{}", style("git-sync configuration wizard").cyan().bold()); - println!( - "{}", - style("Use arrow keys to select options. Press Enter to accept defaults.").dim() - ); + let description = if existing_config { + "Review, add, or delete sync groups." + } else { + "Enter profile or organization URLs, then git-sync will build the mirror group." + }; + println!("{}", style(description).dim()); println!(); - prompt_sites_styled(&mut config, &theme)?; - prompt_mirrors_styled(&mut config, &theme)?; + if existing_config { + print_sync_groups(&config); + } else { + add_sync_group_styled(&mut config, &theme)?; + print_sync_groups(&config); + } + + loop { + match prompt_wizard_action_styled(&theme)? { + WizardAction::AddSyncGroup => { + add_sync_group_styled(&mut config, &theme)?; + print_sync_groups(&config); + } + WizardAction::DeleteSyncGroup => { + if delete_sync_group_styled(&mut config, &theme)? { + print_sync_groups(&config); + } + } + WizardAction::Done => break, + } + } config.save(path)?; println!( @@ -40,122 +80,298 @@ pub fn run_config_wizard(path: &Path) -> Result<()> { Ok(()) } -fn prompt_sites_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<()> { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WizardAction { + AddSyncGroup, + DeleteSyncGroup, + Done, +} + +fn add_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<()> { + let mut endpoints = Vec::new(); + + let first = prompt_target_styled(theme, "Profile/org URL")?; + endpoints.push(ensure_credentials_styled(config, first, theme)?); + + let second = prompt_target_styled(theme, "Profile/org URL to sync with")?; + endpoints.push(ensure_credentials_styled(config, second, theme)?); + loop { - let default = should_add_site_by_default(config); - let prompt = if config.sites.is_empty() { - "Add a site?" + let prompt = if endpoints.len() == 2 { + "Add a third endpoint for 3-way sync?" } else { - "Add another site?" + "Add another endpoint to this sync group?" }; if !Confirm::with_theme(theme) .with_prompt(prompt) - .default(default) + .default(false) .interact()? { break; } - - println!("{}", style("Site").magenta().bold()); - let provider = prompt_provider_styled(theme)?; - let name = Input::::with_theme(theme) - .with_prompt("Site name") - .default(default_site_name(config, &provider)) - .validate_with(|value: &String| validate_required(value)) - .interact_text()?; - let base_url = Input::::with_theme(theme) - .with_prompt("Base URL") - .default(default_base_url(&provider).to_string()) - .interact_text()?; - let api_url = optional_input(theme, "API URL override")?; - let token = prompt_token_styled(theme, &provider, &base_url, api_url.as_deref())?; - let git_username = optional_input(theme, "Git username override")?; - - config.upsert_site(SiteConfig { - name, - provider, - base_url, - api_url, - token, - git_username, - }); - - println!("{}", style("Site saved").green()); - println!(); + let next = prompt_target_styled(theme, "Additional profile/org URL")?; + endpoints.push(ensure_credentials_styled(config, next, theme)?); } + + config.upsert_mirror(MirrorConfig { + name: next_mirror_name(config), + endpoints, + create_missing: true, + visibility: Visibility::Private, + allow_force: false, + }); + Ok(()) } -fn prompt_mirrors_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<()> { - if config.sites.len() < 2 { - println!( - "{}", - style("At least two sites are needed before adding a mirror group.").yellow() - ); - return Ok(()); +fn prompt_wizard_action_styled(theme: &ColorfulTheme) -> Result { + let options = ["Add another sync group", "Delete an existing group", "Done"]; + let index = Select::with_theme(theme) + .with_prompt("What would you like to do?") + .items(options) + .default(0) + .interact()?; + + Ok(match index { + 0 => WizardAction::AddSyncGroup, + 1 => WizardAction::DeleteSyncGroup, + _ => WizardAction::Done, + }) +} + +fn delete_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result { + if config.mirrors.is_empty() { + println!("{}", style("No sync groups to delete.").yellow()); + return Ok(false); } - loop { - let default = config.mirrors.is_empty(); - let prompt = if config.mirrors.is_empty() { - "Add a mirror group?" - } else { - "Add another mirror group?" - }; - if !Confirm::with_theme(theme) - .with_prompt(prompt) - .default(default) - .interact()? - { - break; - } + let mut options = numbered_sync_group_options(config); + options.push("Back".to_string()); + let index = Select::with_theme(theme) + .with_prompt("Delete sync group") + .items(&options) + .default(0) + .interact()?; + if index == config.mirrors.len() { + return Ok(false); + } + let name = config.mirrors[index].name.clone(); + config.remove_mirror(&name)?; + println!( + "{} {}", + style("deleted").red().bold(), + style(format!("sync group {}", index + 1)).cyan() + ); + Ok(true) +} - println!("{}", style("Mirror group").magenta().bold()); - let name = Input::::with_theme(theme) - .with_prompt("Name") +fn prompt_target_styled(theme: &ColorfulTheme, prompt: &str) -> Result { + let url = Input::::with_theme(theme) + .with_prompt(prompt) + .validate_with(|value: &String| validate_required(value)) + .interact_text()?; + let parsed = parse_profile_url(&url)?; + let provider = known_provider_from_host(&parsed.host) + .or_else(|| detect_provider_from_instance(&parsed.base_url)) + .map(Ok) + .unwrap_or_else(|| prompt_provider_styled(theme, &parsed.base_url))?; + let kind = detect_namespace_kind_public(&provider, &parsed.base_url, &parsed.namespace); + + Ok(ProfileTarget { + base_url: parsed.base_url, + provider, + namespace: parsed.namespace, + kind, + }) +} + +fn ensure_credentials_styled( + config: &mut Config, + target: ProfileTarget, + theme: &ColorfulTheme, +) -> Result { + for site in matching_sites(config, &target) { + let kind = match target.kind.clone().or_else(|| { + detect_namespace_kind_with_site(site, &target) + .ok() + .flatten() + }) { + Some(kind) => kind, + None => prompt_namespace_kind_styled(theme, &target.namespace)?, + }; + let endpoint = target_endpoint(&target, kind, site.name.clone()); + if validate_site_for_endpoint(site, &endpoint).is_ok() { + println!( + "{} {}", + style("Using existing credentials for").green(), + style(endpoint_url(site, &endpoint)).cyan() + ); + return Ok(endpoint); + } + } + + let mut transient = TransientCredentialOutput::new(); + transient.write_line(format_args!( + "{} {}", + style("No existing usable credentials for") + .yellow() + .for_stderr(), + style(target_display(&target)).cyan().for_stderr() + ))?; + print_pat_instructions(&mut transient, &target.provider, &target.base_url)?; + + loop { + let token = Password::with_theme(theme) + .with_prompt("PAT token") .validate_with(|value: &String| validate_required(value)) - .interact_text()?; - let mut endpoints = Vec::new(); - loop { - endpoints.push(prompt_endpoint_styled(config, theme)?); - if endpoints.len() >= 2 - && !Confirm::with_theme(theme) - .with_prompt("Add another endpoint to this group?") - .default(false) + .interact_on(transient.term())?; + transient.add_line(); + let site_name = default_site_name(config, &target.base_url, &target.provider); + let site = SiteConfig { + name: site_name, + provider: target.provider.clone(), + base_url: target.base_url.clone(), + api_url: None, + token: TokenConfig::Value(token), + git_username: None, + }; + + let detected_kind = detect_namespace_kind_with_site(&site, &target) + .ok() + .flatten(); + let kind = match target.kind.clone().or(detected_kind) { + Some(kind) => kind, + None => { + let kind = prompt_namespace_kind_styled(theme, &target.namespace)?; + transient.add_line(); + kind + } + }; + let endpoint = target_endpoint(&target, kind, site.name.clone()); + + transient.write_status_prefix(style("Checking PAT... ").dim().for_stderr())?; + match validate_site_for_endpoint(&site, &endpoint) { + Ok(()) => { + transient.finish_status(style("valid").green().bold().for_stderr())?; + transient.clear()?; + let site_name = site.name.clone(); + config.upsert_site(site); + return Ok(endpoint_with_site(&endpoint, site_name)); + } + Err(error) => { + transient.finish_status(style("failed").red().bold().for_stderr())?; + eprintln!( + "{} {error:#}", + style("PAT validation error:").red().for_stderr() + ); + if !Confirm::with_theme(theme) + .with_prompt("Try another PAT?") + .default(true) .interact()? - { - break; + && Confirm::with_theme(theme) + .with_prompt("Save this credential anyway?") + .default(false) + .interact()? + { + let site_name = site.name.clone(); + config.upsert_site(site); + return Ok(endpoint_with_site(&endpoint, site_name)); + } + transient.reset(); } } - - let create_missing = Confirm::with_theme(theme) - .with_prompt("Create missing repositories?") - .default(true) - .interact()?; - let visibility = prompt_visibility_styled(theme)?; - let allow_force = Confirm::with_theme(theme) - .with_prompt("Allow force-push for diverged branches?") - .default(false) - .interact()?; - - config.upsert_mirror(MirrorConfig { - name, - endpoints, - create_missing, - visibility, - allow_force, - }); - - println!("{}", style("Mirror group saved").green()); - println!(); } +} + +struct TransientCredentialOutput { + term: Term, + lines: usize, + status_pending: bool, +} + +impl TransientCredentialOutput { + fn new() -> Self { + Self { + term: Term::stderr(), + lines: 0, + status_pending: false, + } + } + + fn term(&self) -> &Term { + &self.term + } + + fn write_line(&mut self, line: impl Display) -> Result<()> { + self.term.write_line(&line.to_string())?; + self.lines += 1; + Ok(()) + } + + fn write_status_prefix(&mut self, prefix: impl Display) -> Result<()> { + self.term.write_str(&prefix.to_string())?; + self.term.flush()?; + self.status_pending = true; + Ok(()) + } + + fn finish_status(&mut self, status: impl Display) -> Result<()> { + self.term.write_line(&status.to_string())?; + if self.status_pending { + self.lines += 1; + self.status_pending = false; + } + Ok(()) + } + + fn add_line(&mut self) { + self.lines += 1; + } + + fn clear(&self) -> Result<()> { + if self.lines > 0 && self.term.is_term() { + self.term.clear_last_lines(self.lines)?; + } + Ok(()) + } + + fn reset(&mut self) { + self.lines = 0; + self.status_pending = false; + } +} + +fn validate_site_for_endpoint(site: &SiteConfig, endpoint: &EndpointConfig) -> Result<()> { + let client = ProviderClient::new(site)?; + client.validate_token()?; + client + .list_repos(endpoint) + .with_context(|| "token was valid, but repository access check failed")?; Ok(()) } -fn prompt_provider_styled(theme: &ColorfulTheme) -> Result { +fn detect_namespace_kind_with_site( + site: &SiteConfig, + target: &ProfileTarget, +) -> Result> { + ProviderClient::new(site)?.detect_namespace_kind(&target.namespace) +} + +fn matching_sites<'a>(config: &'a Config, target: &ProfileTarget) -> Vec<&'a SiteConfig> { + config + .sites + .iter() + .filter(|site| { + site.provider == target.provider + && trim_url_end(&site.base_url) == trim_url_end(&target.base_url) + }) + .collect() +} + +fn prompt_provider_styled(theme: &ColorfulTheme, base_url: &str) -> Result { let options = ["GitHub", "GitLab", "Gitea"]; let index = Select::with_theme(theme) - .with_prompt("Provider") + .with_prompt(format!("Provider for {base_url}")) .items(options) .default(0) .interact()?; @@ -166,34 +382,10 @@ fn prompt_provider_styled(theme: &ColorfulTheme) -> Result { }) } -fn prompt_endpoint_styled(config: &Config, theme: &ColorfulTheme) -> Result { - let site_names = config - .sites - .iter() - .map(|site| site.name.as_str()) - .collect::>(); - let site_index = Select::with_theme(theme) - .with_prompt("Endpoint site") - .items(&site_names) - .default(0) - .interact()?; - let kind = prompt_namespace_kind_styled(theme)?; - let namespace = Input::::with_theme(theme) - .with_prompt("Namespace/account/org/group") - .validate_with(|value: &String| validate_required(value)) - .interact_text()?; - - Ok(EndpointConfig { - site: site_names[site_index].to_string(), - kind, - namespace, - }) -} - -fn prompt_namespace_kind_styled(theme: &ColorfulTheme) -> Result { +fn prompt_namespace_kind_styled(theme: &ColorfulTheme, namespace: &str) -> Result { let options = ["User", "Organization", "Group"]; let index = Select::with_theme(theme) - .with_prompt("Namespace kind") + .with_prompt(format!("What is {namespace}?")) .items(options) .default(0) .interact()?; @@ -204,84 +396,87 @@ fn prompt_namespace_kind_styled(theme: &ColorfulTheme) -> Result }) } -fn prompt_visibility_styled(theme: &ColorfulTheme) -> Result { - let options = ["Private", "Public"]; - let index = Select::with_theme(theme) - .with_prompt("Visibility for created repos") - .items(options) - .default(0) - .interact()?; - Ok(if index == 0 { - Visibility::Private - } else { - Visibility::Public - }) +fn print_sync_groups(config: &Config) { + println!(); + println!("{}", style("Sync groups").cyan().bold()); + if config.mirrors.is_empty() { + println!( + " {} {}", + style("-").cyan(), + style("No sync groups configured.").dim() + ); + println!(); + return; + } + for (index, mirror) in config.mirrors.iter().enumerate() { + println!(" {}. {}", index + 1, sync_group_summary(config, mirror)); + } + println!(); } -fn prompt_token_styled( - theme: &ColorfulTheme, +fn numbered_sync_group_options(config: &Config) -> Vec { + config + .mirrors + .iter() + .enumerate() + .map(|(index, mirror)| format!("{}. {}", index + 1, sync_group_summary(config, mirror))) + .collect() +} + +#[cfg(test)] +fn sync_group_summaries(config: &Config) -> Vec { + config + .mirrors + .iter() + .map(|mirror| sync_group_summary(config, mirror)) + .collect() +} + +fn sync_group_summary(config: &Config, mirror: &MirrorConfig) -> String { + mirror + .endpoints + .iter() + .map(|endpoint| { + config + .site(&endpoint.site) + .map(|site| endpoint_url(site, endpoint)) + .unwrap_or_else(|| format!("{}:{}", endpoint.site, endpoint.namespace)) + }) + .collect::>() + .join(" <-> ") +} + +fn print_pat_instructions( + output: &mut TransientCredentialOutput, provider: &ProviderKind, base_url: &str, - api_url: Option<&str>, -) -> Result { - println!( - "{}", - style("The PAT is stored in the config file, which git-sync writes with user-only permissions.").yellow() - ); - loop { - let token = Password::with_theme(theme) - .with_prompt("PAT token") - .validate_with(|value: &String| validate_required(value)) - .interact()?; - let token_config = TokenConfig::Value(token); - let site = SiteConfig { - name: "validation".to_string(), - provider: provider.clone(), - base_url: base_url.to_string(), - api_url: api_url.map(ToString::to_string), - token: token_config.clone(), - git_username: None, - }; - - print!("{}", style("Checking PAT... ").dim()); - io::stdout().flush()?; - match validate_site_token(&site) { - Ok(()) => { - println!("{}", style("valid").green().bold()); - return Ok(token_config); - } - Err(error) => { - println!("{}", style("failed").red().bold()); - println!("{} {error:#}", style("PAT validation error:").red()); - if Confirm::with_theme(theme) - .with_prompt("Use this token anyway?") - .default(false) - .interact()? - { - return Ok(token_config); - } - } - } +) -> Result<()> { + output.write_line(style("PAT setup").cyan().bold().for_stderr())?; + for line in pat_instruction_lines(provider, base_url) { + output.write_line(format_args!(" {} {line}", style("-").cyan().for_stderr()))?; } + Ok(()) } -fn validate_site_token(site: &SiteConfig) -> Result<()> { - ProviderClient::new(site)?.validate_token() -} - -fn optional_input(theme: &ColorfulTheme, prompt: &str) -> Result> { - let value = Input::::with_theme(theme) - .with_prompt(prompt) - .allow_empty(true) - .interact_text()?; - Ok((!value.trim().is_empty()).then(|| value.trim().to_string())) -} - -fn validate_required(value: &str) -> std::result::Result<(), String> { - if value.trim().is_empty() { - Err("A value is required".to_string()) - } else { - Ok(()) +fn pat_instruction_lines(provider: &ProviderKind, base_url: &str) -> Vec { + let url = token_creation_url(provider, base_url); + match provider { + ProviderKind::Github => vec![ + "Create a classic PAT with repo permissions.".to_string(), + format!("Open: {url}"), + "Generate new token (classic), select repo, generate, then paste the token here." + .to_string(), + ], + ProviderKind::Gitlab => vec![ + "Create a personal access token with API permissions.".to_string(), + format!("Open: {url}"), + "Select api, create the token, then paste it here.".to_string(), + ], + ProviderKind::Gitea => vec![ + "Create a personal access token with repository permissions.".to_string(), + format!("Open: {url}"), + "Generate a new token, allow repository access, then paste it here.".to_string(), + ], } } @@ -296,157 +491,197 @@ where W: Write, { writeln!(writer, "git-sync configuration wizard")?; - writeln!(writer, "Press Enter to accept defaults shown in brackets.")?; - - prompt_sites(&mut config, reader, writer)?; - prompt_mirrors(&mut config, reader, writer)?; + if config.mirrors.is_empty() { + add_sync_group(reader, writer, &mut config)?; + write_sync_groups(&config, writer)?; + } else { + write_sync_groups(&config, writer)?; + } + loop { + match prompt_wizard_action(reader, writer)? { + WizardAction::AddSyncGroup => { + add_sync_group(reader, writer, &mut config)?; + write_sync_groups(&config, writer)?; + } + WizardAction::DeleteSyncGroup => { + if delete_sync_group(reader, writer, &mut config)? { + write_sync_groups(&config, writer)?; + } + } + WizardAction::Done => break, + } + } Ok(config) } #[cfg(test)] -fn prompt_sites(config: &mut Config, reader: &mut R, writer: &mut W) -> Result<()> +fn add_sync_group(reader: &mut R, writer: &mut W, config: &mut Config) -> Result<()> where R: BufRead, W: Write, { - loop { - let default = should_add_site_by_default(config); - if !prompt_bool( - reader, - writer, - if config.sites.is_empty() { - "Add a site?" - } else { - "Add another site?" - }, - default, - )? { - break; - } + let mut endpoints = Vec::new(); + let first = prompt_target(reader, writer, "Profile/org URL")?; + endpoints.push(ensure_credentials(config, first, reader, writer)?); + let second = prompt_target(reader, writer, "Profile/org URL to sync with")?; + endpoints.push(ensure_credentials(config, second, reader, writer)?); - let provider = prompt_provider(reader, writer)?; - let name = prompt_with_default( - reader, - writer, - "Site name", - &default_site_name(config, &provider), - )?; - let base_url = - prompt_with_default(reader, writer, "Base URL", default_base_url(&provider))?; - let api_url = prompt_optional(reader, writer, "API URL override")?; - let token = prompt_token(reader, writer)?; - let git_username = prompt_optional(reader, writer, "Git username override")?; - - config.upsert_site(SiteConfig { - name, - provider, - base_url, - api_url, - token, - git_username, - }); + while prompt_bool( + reader, + writer, + "Add a third endpoint for 3-way sync?", + false, + )? { + let next = prompt_target(reader, writer, "Additional profile/org URL")?; + endpoints.push(ensure_credentials(config, next, reader, writer)?); } + config.upsert_mirror(MirrorConfig { + name: next_mirror_name(config), + endpoints, + create_missing: true, + visibility: Visibility::Private, + allow_force: false, + }); Ok(()) } #[cfg(test)] -fn prompt_mirrors(config: &mut Config, reader: &mut R, writer: &mut W) -> Result<()> +fn prompt_wizard_action(reader: &mut R, writer: &mut W) -> Result where R: BufRead, W: Write, { - if config.sites.len() < 2 { - writeln!( - writer, - "At least two sites are needed before adding a mirror group." - )?; - return Ok(()); - } - loop { - let default = config.mirrors.is_empty(); - if !prompt_bool( - reader, - writer, - if config.mirrors.is_empty() { - "Add a mirror group?" - } else { - "Add another mirror group?" - }, - default, - )? { - break; - } - - let name = prompt_required(reader, writer, "Mirror group name")?; - let mut endpoints = Vec::new(); - loop { - writeln!(writer, "Available sites: {}", site_names(config))?; - let endpoint = prompt_endpoint(config, reader, writer)?; - endpoints.push(endpoint); - - if endpoints.len() >= 2 - && !prompt_bool(reader, writer, "Add another endpoint to this group?", false)? - { - break; + writeln!(writer, "What would you like to do?")?; + writeln!(writer, " 1. Add another sync group")?; + writeln!(writer, " 2. Delete an existing group")?; + writeln!(writer, " 3. Done")?; + write!(writer, "Choose an option: ")?; + writer.flush()?; + let value = read_line(reader)?.trim().to_ascii_lowercase(); + match value.as_str() { + "1" | "add" | "add another sync group" => return Ok(WizardAction::AddSyncGroup), + "2" | "delete" | "delete an existing group" => { + return Ok(WizardAction::DeleteSyncGroup); } + "3" | "done" | "finish" => return Ok(WizardAction::Done), + _ => writeln!(writer, "Enter 1, 2, or 3.")?, } - - let create_missing = prompt_bool(reader, writer, "Create missing repositories?", true)?; - let visibility = prompt_visibility(reader, writer)?; - let allow_force = prompt_bool( - reader, - writer, - "Allow force-push for diverged branches?", - false, - )?; - - config.upsert_mirror(MirrorConfig { - name, - endpoints, - create_missing, - visibility, - allow_force, - }); } - - Ok(()) } #[cfg(test)] -fn prompt_endpoint(config: &Config, reader: &mut R, writer: &mut W) -> Result +fn delete_sync_group(reader: &mut R, writer: &mut W, config: &mut Config) -> Result where R: BufRead, W: Write, { - let site = loop { - let site = prompt_required(reader, writer, "Endpoint site name")?; - if config.site(&site).is_some() { - break site; - } - writeln!(writer, "Unknown site '{site}'.")?; - }; - let kind = prompt_namespace_kind(reader, writer)?; - let namespace = prompt_required(reader, writer, "Namespace/account/org/group")?; + if config.mirrors.is_empty() { + writeln!(writer, "No sync groups to delete.")?; + return Ok(false); + } - Ok(EndpointConfig { - site, - kind, - namespace, + loop { + writeln!(writer, "Delete sync group")?; + for (index, option) in sync_group_summaries(config).iter().enumerate() { + writeln!(writer, " {}. {}", index + 1, option)?; + } + writeln!(writer, " {}. Back", config.mirrors.len() + 1)?; + write!(writer, "Choose a sync group: ")?; + writer.flush()?; + let value = read_line(reader)?.trim().to_ascii_lowercase(); + if value == "b" || value == "back" { + return Ok(false); + } + match value.parse::() { + Ok(index) if (1..=config.mirrors.len()).contains(&index) => { + let name = config.mirrors[index - 1].name.clone(); + config.remove_mirror(&name)?; + writeln!(writer, "deleted sync group {index}")?; + return Ok(true); + } + Ok(index) if index == config.mirrors.len() + 1 => return Ok(false), + _ => writeln!(writer, "Enter a sync group number, or choose Back.")?, + } + } +} + +#[cfg(test)] +fn prompt_target(reader: &mut R, writer: &mut W, prompt: &str) -> Result +where + R: BufRead, + W: Write, +{ + let url = prompt_required(reader, writer, prompt)?; + let parsed = parse_profile_url(&url)?; + let provider = known_provider_from_host(&parsed.host).unwrap_or_else(|| { + prompt_provider(reader, writer, &parsed.base_url).expect("provider prompt failed") + }); + Ok(ProfileTarget { + base_url: parsed.base_url, + provider, + namespace: parsed.namespace, + kind: None, }) } #[cfg(test)] -fn prompt_provider(reader: &mut R, writer: &mut W) -> Result +fn ensure_credentials( + config: &mut Config, + target: ProfileTarget, + reader: &mut R, + writer: &mut W, +) -> Result +where + R: BufRead, + W: Write, +{ + if let Some(site) = config.sites.iter().find(|site| { + site.provider == target.provider + && trim_url_end(&site.base_url) == trim_url_end(&target.base_url) + }) { + let kind = target.kind.clone().unwrap_or_else(|| { + prompt_namespace_kind(reader, writer, &target.namespace).expect("kind prompt failed") + }); + let endpoint = target_endpoint(&target, kind, site.name.clone()); + writeln!( + writer, + "Using existing credentials for {}", + target_display(&target) + )?; + return Ok(endpoint); + } + + for line in pat_instruction_lines(&target.provider, &target.base_url) { + writeln!(writer, "{line}")?; + } + let token = prompt_required(reader, writer, "PAT token")?; + let site = SiteConfig { + name: default_site_name(config, &target.base_url, &target.provider), + provider: target.provider.clone(), + base_url: target.base_url.clone(), + api_url: None, + token: TokenConfig::Value(token), + git_username: None, + }; + let site_name = site.name.clone(); + config.upsert_site(site); + let kind = target.kind.clone().unwrap_or_else(|| { + prompt_namespace_kind(reader, writer, &target.namespace).expect("kind prompt failed") + }); + Ok(target_endpoint(&target, kind, site_name)) +} + +#[cfg(test)] +fn prompt_provider(reader: &mut R, writer: &mut W, base_url: &str) -> Result where R: BufRead, W: Write, { loop { - let value = - prompt_with_default(reader, writer, "Provider (github/gitlab/gitea)", "github")?; + let value = prompt_required(reader, writer, &format!("Provider for {base_url}"))?; match value.to_ascii_lowercase().as_str() { "github" => return Ok(ProviderKind::Github), "gitlab" => return Ok(ProviderKind::Gitlab), @@ -457,13 +692,17 @@ where } #[cfg(test)] -fn prompt_namespace_kind(reader: &mut R, writer: &mut W) -> Result +fn prompt_namespace_kind( + reader: &mut R, + writer: &mut W, + namespace: &str, +) -> Result where R: BufRead, W: Write, { loop { - let value = prompt_with_default(reader, writer, "Namespace kind (user/org/group)", "user")?; + let value = prompt_with_default(reader, writer, &format!("What is {namespace}?"), "user")?; match value.to_ascii_lowercase().as_str() { "user" => return Ok(NamespaceKind::User), "org" | "organization" => return Ok(NamespaceKind::Org), @@ -474,41 +713,24 @@ where } #[cfg(test)] -fn prompt_visibility(reader: &mut R, writer: &mut W) -> Result +fn write_sync_groups(config: &Config, writer: &mut W) -> Result<()> where - R: BufRead, W: Write, { - loop { - let value = prompt_with_default( - reader, - writer, - "Visibility for created repos (private/public)", - "private", - )?; - match value.to_ascii_lowercase().as_str() { - "private" => return Ok(Visibility::Private), - "public" => return Ok(Visibility::Public), - _ => writeln!(writer, "Visibility must be private or public.")?, - } + writeln!(writer, "Sync groups")?; + if config.mirrors.is_empty() { + writeln!(writer, "No sync groups configured.")?; + return Ok(()); } -} - -#[cfg(test)] -fn prompt_token(reader: &mut R, writer: &mut W) -> Result -where - R: BufRead, - W: Write, -{ - writeln!( - writer, - "The PAT is stored in the config file with user-only permissions." - )?; - Ok(TokenConfig::Value(prompt_required( - reader, - writer, - "PAT token", - )?)) + for (index, mirror) in config.mirrors.iter().enumerate() { + writeln!( + writer, + "{}. {}", + index + 1, + sync_group_summary(config, mirror) + )?; + } + Ok(()) } #[cfg(test)] @@ -548,18 +770,6 @@ where } } -#[cfg(test)] -fn prompt_optional(reader: &mut R, writer: &mut W, label: &str) -> Result> -where - R: BufRead, - W: Write, -{ - write!(writer, "{label} [none]: ")?; - writer.flush()?; - let value = read_line(reader)?.trim().to_string(); - Ok((!value.is_empty()).then_some(value)) -} - #[cfg(test)] fn prompt_with_default( reader: &mut R, @@ -594,6 +804,200 @@ where Ok(value) } +fn parse_profile_url(value: &str) -> Result { + let normalized = ensure_url_scheme(value); + let parsed = + Url::parse(&normalized).with_context(|| format!("invalid profile URL '{value}'"))?; + let host = parsed + .host_str() + .context("profile URL must include a host")? + .trim_start_matches("www.") + .to_ascii_lowercase(); + let namespace = parsed + .path_segments() + .map(|segments| { + segments + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("/") + }) + .filter(|path| !path.is_empty()) + .context("profile URL must include a user or organization path")?; + + let mut base_url = format!("{}://{}", parsed.scheme(), host); + if let Some(port) = parsed.port() { + base_url.push_str(&format!(":{port}")); + } + + Ok(ParsedProfileUrl { + base_url, + host, + namespace, + }) +} + +fn known_provider_from_host(host: &str) -> Option { + let host = host.trim_start_matches("www.").to_ascii_lowercase(); + if host == "github.com" || host.ends_with(".github.com") || host.contains("github") { + Some(ProviderKind::Github) + } else if host == "gitlab.com" || host.ends_with(".gitlab.com") || host.contains("gitlab") { + Some(ProviderKind::Gitlab) + } else if host.contains("gitea") { + Some(ProviderKind::Gitea) + } else { + None + } +} + +fn detect_provider_from_instance(base_url: &str) -> Option { + let client = Client::builder() + .timeout(Duration::from_secs(3)) + .build() + .ok()?; + let base = trim_url_end(base_url); + if client + .get(format!("{base}/api/v1/version")) + .send() + .ok() + .is_some_and(|response| response.status().is_success()) + { + return Some(ProviderKind::Gitea); + } + if client + .get(format!("{base}/api/v4/version")) + .send() + .ok() + .is_some_and(|response| response.status().is_success()) + { + return Some(ProviderKind::Gitlab); + } + if client + .get(format!("{base}/api/v3/meta")) + .send() + .ok() + .is_some_and(|response| response.status().is_success()) + { + return Some(ProviderKind::Github); + } + None +} + +fn detect_namespace_kind_public( + provider: &ProviderKind, + base_url: &str, + namespace: &str, +) -> Option { + let client = Client::builder() + .timeout(Duration::from_secs(3)) + .build() + .ok()?; + let site = SiteConfig { + name: "detect".to_string(), + provider: provider.clone(), + base_url: base_url.to_string(), + api_url: None, + token: TokenConfig::Value(String::new()), + git_username: None, + }; + let api_base = site.api_base(); + match provider { + ProviderKind::Github => { + let url = format!("{api_base}/users/{namespace}"); + let value = client + .get(url) + .send() + .ok()? + .json::() + .ok()?; + match value.get("type")?.as_str()? { + "Organization" => Some(NamespaceKind::Org), + "User" => Some(NamespaceKind::User), + _ => None, + } + } + ProviderKind::Gitlab => { + let encoded = urlencoding(namespace); + if client + .get(format!("{api_base}/groups/{encoded}")) + .send() + .ok() + .is_some_and(|response| response.status().is_success()) + { + return Some(NamespaceKind::Group); + } + let encoded = urlencoding(namespace.rsplit('/').next().unwrap_or(namespace)); + let users = client + .get(format!("{api_base}/users?username={encoded}")) + .send() + .ok()? + .json::() + .ok()?; + users + .as_array() + .is_some_and(|items| !items.is_empty()) + .then_some(NamespaceKind::User) + } + ProviderKind::Gitea => { + if client + .get(format!("{api_base}/orgs/{namespace}")) + .send() + .ok() + .is_some_and(|response| response.status().is_success()) + { + return Some(NamespaceKind::Org); + } + client + .get(format!("{api_base}/users/{namespace}")) + .send() + .ok() + .is_some_and(|response| response.status().is_success()) + .then_some(NamespaceKind::User) + } + } +} + +fn endpoint_with_site(endpoint: &EndpointConfig, site: String) -> EndpointConfig { + EndpointConfig { + site, + kind: endpoint.kind.clone(), + namespace: endpoint.namespace.clone(), + } +} + +fn target_endpoint(target: &ProfileTarget, kind: NamespaceKind, site: String) -> EndpointConfig { + EndpointConfig { + site, + kind, + namespace: target.namespace.clone(), + } +} + +fn target_display(target: &ProfileTarget) -> String { + format!("{}/{}", trim_url_scheme(&target.base_url), target.namespace) +} + +fn endpoint_url(site: &SiteConfig, endpoint: &EndpointConfig) -> String { + format!("{}/{}", trim_url_scheme(&site.base_url), endpoint.namespace) +} + +fn trim_url_scheme(value: &str) -> String { + value + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_end_matches('/') + .to_string() +} + +fn next_mirror_name(config: &Config) -> String { + for index in 1.. { + let candidate = format!("sync-{index}"); + if config.mirrors.iter().all(|mirror| mirror.name != candidate) { + return candidate; + } + } + unreachable!("unbounded suffix search should always return") +} + fn default_base_url(provider: &ProviderKind) -> &'static str { match provider { ProviderKind::Github => "https://github.com", @@ -602,18 +1006,14 @@ fn default_base_url(provider: &ProviderKind) -> &'static str { } } -fn should_add_site_by_default(config: &Config) -> bool { - config.sites.len() < 2 -} - -fn default_site_name(config: &Config, provider: &ProviderKind) -> String { - let base = match provider { - ProviderKind::Github => "github", - ProviderKind::Gitlab => "gitlab", - ProviderKind::Gitea => "gitea", +fn default_site_name(config: &Config, base_url: &str, provider: &ProviderKind) -> String { + let base = if trim_url_end(base_url) == default_base_url(provider) { + provider_slug(provider).to_string() + } else { + site_name_from_url(base_url).unwrap_or_else(|| provider_slug(provider).to_string()) }; - if config.site(base).is_none() { - return base.to_string(); + if config.site(&base).is_none() { + return base; } for suffix in 2.. { @@ -625,14 +1025,84 @@ fn default_site_name(config: &Config, provider: &ProviderKind) -> String { unreachable!("unbounded suffix search should always return") } -#[cfg(test)] -fn site_names(config: &Config) -> String { - config - .sites - .iter() - .map(|site| site.name.as_str()) - .collect::>() - .join(", ") +fn site_name_from_url(base_url: &str) -> Option { + let normalized_url = ensure_url_scheme(base_url); + let parsed = Url::parse(&normalized_url).ok()?; + let host = parsed + .host_str()? + .trim_start_matches("www.") + .to_ascii_lowercase(); + let mut labels = host.split('.').collect::>(); + if matches!( + labels.last(), + Some(&"com" | &"org" | &"net" | &"io" | &"dev") + ) { + labels.pop(); + } + let candidate = labels.join("-"); + let normalized = normalize_site_name(&candidate); + (!normalized.is_empty()).then_some(normalized) +} + +fn normalize_site_name(value: &str) -> String { + let mut output = String::new(); + let mut previous_dash = false; + for ch in value.chars() { + let ch = ch.to_ascii_lowercase(); + if ch.is_ascii_alphanumeric() { + output.push(ch); + previous_dash = false; + } else if !previous_dash { + output.push('-'); + previous_dash = true; + } + } + output.trim_matches('-').to_string() +} + +fn provider_slug(provider: &ProviderKind) -> &'static str { + match provider { + ProviderKind::Github => "github", + ProviderKind::Gitlab => "gitlab", + ProviderKind::Gitea => "gitea", + } +} + +fn token_creation_url(provider: &ProviderKind, base_url: &str) -> String { + let base = ensure_url_scheme(base_url) + .trim_end_matches('/') + .to_string(); + match provider { + ProviderKind::Github => format!("{base}/settings/tokens"), + ProviderKind::Gitlab => { + format!("{base}/-/user_settings/personal_access_tokens?name=git-sync&scopes=api") + } + ProviderKind::Gitea => format!("{base}/user/settings/applications"), + } +} + +fn ensure_url_scheme(value: &str) -> String { + if value.contains("://") { + value.to_string() + } else { + format!("https://{value}") + } +} + +fn trim_url_end(value: &str) -> &str { + value.trim_end_matches('/') +} + +fn urlencoding(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +fn validate_required(value: &str) -> std::result::Result<(), String> { + if value.trim().is_empty() { + Err("A value is required".to_string()) + } else { + Ok(()) + } } #[cfg(test)] @@ -641,36 +1111,16 @@ mod tests { use std::io::Cursor; #[test] - fn wizard_builds_sites_and_mirror_group() { + fn wizard_builds_sync_group_from_profile_urls() { let input = [ - "", // Add a site? yes - "", // Provider default github - "", // Site name default github - "", // Base URL default - "", // API URL override none - "gh-token", // PAT token - "", // Git username override none - "", // Add another site? yes by default for second site - "gitea", // Provider - "", // Site name default gitea - "https://gitea.example.test", // Base URL - "", // API URL override none - "gt-token", // PAT token - "", // Git username override none - "n", // Stop adding sites - "", // Add mirror group? yes - "personal", // Mirror group name - "github", // First endpoint site - "", // Namespace kind default user - "hykilpikonna", // Namespace - "gitea", // Second endpoint site - "", // Namespace kind default user - "azalea", // Namespace - "n", // Stop adding endpoints - "", // Create missing default yes - "", // Visibility default private - "", // Allow force default no - "n", // Stop adding mirrors + "https://github.com/hykilpikonna", + "gh-token", + "", + "https://gitea.example.test/azalea", + "gt-token", + "", + "n", + "3", ] .join("\n") + "\n"; @@ -688,51 +1138,247 @@ mod tests { config.sites[0].token, TokenConfig::Value("gh-token".to_string()) ); - assert_eq!(config.sites[1].name, "gitea"); + assert_eq!(config.sites[1].name, "gitea-example-test"); assert_eq!(config.sites[1].provider, ProviderKind::Gitea); assert_eq!(config.sites[1].base_url, "https://gitea.example.test"); - assert_eq!( - config.sites[1].token, - TokenConfig::Value("gt-token".to_string()) - ); assert_eq!(config.mirrors.len(), 1); - assert_eq!(config.mirrors[0].name, "personal"); + assert_eq!(config.mirrors[0].name, "sync-1"); assert_eq!(config.mirrors[0].endpoints.len(), 2); assert_eq!(config.mirrors[0].endpoints[0].site, "github"); assert_eq!(config.mirrors[0].endpoints[0].kind, NamespaceKind::User); assert_eq!(config.mirrors[0].endpoints[0].namespace, "hykilpikonna"); - assert_eq!(config.mirrors[0].endpoints[1].site, "gitea"); + assert_eq!(config.mirrors[0].endpoints[1].site, "gitea-example-test"); assert_eq!(config.mirrors[0].endpoints[1].namespace, "azalea"); assert!(config.mirrors[0].create_missing); assert_eq!(config.mirrors[0].visibility, Visibility::Private); assert!(!config.mirrors[0].allow_force); let output = String::from_utf8(output).unwrap(); - assert!(output.contains("git-sync configuration wizard")); - assert!(output.contains("Available sites: github, gitea")); + assert!(output.contains("1. github.com/hykilpikonna <-> gitea.example.test/azalea")); + assert!(output.contains("Add another sync group")); + assert!(output.contains("Delete an existing group")); + assert!(output.contains("Done")); } #[test] - fn wizard_can_update_existing_config_without_adding_anything() { + fn wizard_can_build_three_way_sync() { + let input = [ + "https://github.com/alice", + "gh-token", + "", + "https://gitlab.com/alice", + "gl-token", + "", + "y", + "https://gitea.example.test/alice", + "gt-token", + "", + "n", + "3", + ] + .join("\n") + + "\n"; + let mut reader = Cursor::new(input.as_bytes()); + let mut output = Vec::new(); + + let config = + run_config_wizard_with_io(Config::default(), &mut reader, &mut output).unwrap(); + + assert_eq!(config.mirrors.len(), 1); + assert_eq!(config.mirrors[0].endpoints.len(), 3); + assert_eq!(config.sites.len(), 3); + } + + #[test] + fn wizard_reuses_existing_credentials_for_same_instance() { let config = Config { sites: vec![SiteConfig { name: "github".to_string(), provider: ProviderKind::Github, base_url: "https://github.com".to_string(), api_url: None, - token: TokenConfig::Env("GITHUB_TOKEN".to_string()), + token: TokenConfig::Value("existing".to_string()), git_username: None, }], mirrors: Vec::new(), }; - let mut reader = Cursor::new(b"n\n".as_slice()); + let input = [ + "https://github.com/alice", + "", + "https://github.com/bob", + "", + "n", + "3", + ] + .join("\n") + + "\n"; + let mut reader = Cursor::new(input.as_bytes()); let mut output = Vec::new(); - let updated = run_config_wizard_with_io(config.clone(), &mut reader, &mut output).unwrap(); + let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap(); + + assert_eq!(updated.sites.len(), 1); + assert_eq!(updated.mirrors[0].endpoints[0].site, "github"); + assert_eq!(updated.mirrors[0].endpoints[1].site, "github"); + } + + #[test] + fn wizard_starts_existing_config_at_sync_group_menu() { + let config = Config { + sites: vec![ + SiteConfig { + name: "github".to_string(), + provider: ProviderKind::Github, + base_url: "https://github.com".to_string(), + api_url: None, + token: TokenConfig::Value("existing-gh".to_string()), + git_username: None, + }, + SiteConfig { + name: "gitea".to_string(), + provider: ProviderKind::Gitea, + base_url: "https://gitea.example.test".to_string(), + api_url: None, + token: TokenConfig::Value("existing-gt".to_string()), + git_username: None, + }, + ], + mirrors: vec![MirrorConfig { + name: "sync-1".to_string(), + endpoints: vec![ + EndpointConfig { + site: "github".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + EndpointConfig { + site: "gitea".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + ], + create_missing: true, + visibility: Visibility::Private, + allow_force: false, + }], + }; + let mut reader = Cursor::new(b"3\n".as_slice()); + let mut output = Vec::new(); + + let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap(); + + assert_eq!(updated.mirrors.len(), 1); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("1. github.com/alice <-> gitea.example.test/alice")); + assert!(output.contains("What would you like to do?")); + assert!(!output.contains("Profile/org URL:")); + } + + #[test] + fn wizard_deletes_existing_sync_group_from_menu() { + let config = Config { + sites: vec![ + SiteConfig { + name: "github".to_string(), + provider: ProviderKind::Github, + base_url: "https://github.com".to_string(), + api_url: None, + token: TokenConfig::Value("existing-gh".to_string()), + git_username: None, + }, + SiteConfig { + name: "gitea".to_string(), + provider: ProviderKind::Gitea, + base_url: "https://gitea.example.test".to_string(), + api_url: None, + token: TokenConfig::Value("existing-gt".to_string()), + git_username: None, + }, + ], + mirrors: vec![MirrorConfig { + name: "sync-1".to_string(), + endpoints: vec![ + EndpointConfig { + site: "github".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + EndpointConfig { + site: "gitea".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + ], + create_missing: true, + visibility: Visibility::Private, + allow_force: false, + }], + }; + let input = ["2", "1", "3"].join("\n") + "\n"; + let mut reader = Cursor::new(input.as_bytes()); + let mut output = Vec::new(); + + let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap(); - assert_eq!(updated.sites, config.sites); assert!(updated.mirrors.is_empty()); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Delete sync group")); + assert!(output.contains("2. Back")); + assert!(output.contains("deleted sync group 1")); + assert!(output.contains("No sync groups configured.")); + } + + #[test] + fn wizard_can_go_back_from_delete_menu() { + let config = Config { + sites: vec![ + SiteConfig { + name: "github".to_string(), + provider: ProviderKind::Github, + base_url: "https://github.com".to_string(), + api_url: None, + token: TokenConfig::Value("existing-gh".to_string()), + git_username: None, + }, + SiteConfig { + name: "gitea".to_string(), + provider: ProviderKind::Gitea, + base_url: "https://gitea.example.test".to_string(), + api_url: None, + token: TokenConfig::Value("existing-gt".to_string()), + git_username: None, + }, + ], + mirrors: vec![MirrorConfig { + name: "sync-1".to_string(), + endpoints: vec![ + EndpointConfig { + site: "github".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + EndpointConfig { + site: "gitea".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + ], + create_missing: true, + visibility: Visibility::Private, + allow_force: false, + }], + }; + let input = ["2", "2", "3"].join("\n") + "\n"; + let mut reader = Cursor::new(input.as_bytes()); + let mut output = Vec::new(); + + let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap(); + + assert_eq!(updated.mirrors.len(), 1); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("2. Back")); + assert!(!output.contains("deleted sync group")); } #[test] @@ -748,43 +1394,32 @@ mod tests { } #[test] - fn wizard_defaults_to_second_site_then_stops_after_two_sites() { - let input = [ - "", // Add a site? yes - "", // Provider default github - "", // Site name default github - "", // Base URL default - "", // API URL override none - "gh-token", // PAT token - "", // Git username override none - "", // Add another site? yes by default - "gitea", // Provider - "", // Site name default gitea - "", // Base URL default - "", // API URL override none - "gt-token", // PAT token - "", // Git username override none - "", // Add another site? no by default after two sites - "n", // Add mirror group? no for this focused test - ] - .join("\n") - + "\n"; - let mut reader = Cursor::new(input.as_bytes()); - let mut output = Vec::new(); + fn profile_urls_are_parsed_into_base_and_namespace() { + let parsed = parse_profile_url("github.com/alice").unwrap(); + assert_eq!(parsed.base_url, "https://github.com"); + assert_eq!(parsed.host, "github.com"); + assert_eq!(parsed.namespace, "alice"); - let config = - run_config_wizard_with_io(Config::default(), &mut reader, &mut output).unwrap(); - - assert_eq!(config.sites.len(), 2); - assert_eq!(config.sites[0].name, "github"); - assert_eq!(config.sites[1].name, "gitea"); - assert!(config.mirrors.is_empty()); + let parsed = parse_profile_url("https://gitlab.example.test:8443/groups/team").unwrap(); + assert_eq!(parsed.base_url, "https://gitlab.example.test:8443"); + assert_eq!(parsed.namespace, "groups/team"); } #[test] - fn default_site_names_are_provider_based_and_unique() { + fn site_names_are_derived_from_urls_and_made_unique() { let mut config = Config::default(); - assert_eq!(default_site_name(&config, &ProviderKind::Github), "github"); + assert_eq!( + default_site_name(&config, "https://github.com", &ProviderKind::Github), + "github" + ); + assert_eq!( + default_site_name( + &config, + "https://git.my-company.com:3000", + &ProviderKind::Gitea + ), + "git-my-company" + ); config.upsert_site(SiteConfig { name: "github".to_string(), @@ -795,21 +1430,24 @@ mod tests { git_username: None, }); assert_eq!( - default_site_name(&config, &ProviderKind::Github), + default_site_name(&config, "https://github.com", &ProviderKind::Github), "github-2" ); + } - config.upsert_site(SiteConfig { - name: "github-2".to_string(), - provider: ProviderKind::Github, - base_url: "https://github.example.test".to_string(), - api_url: None, - token: TokenConfig::Value("token".to_string()), - git_username: None, - }); + #[test] + fn token_creation_urls_are_provider_specific() { assert_eq!( - default_site_name(&config, &ProviderKind::Github), - "github-3" + token_creation_url(&ProviderKind::Github, "https://github.com/"), + "https://github.com/settings/tokens" + ); + assert_eq!( + token_creation_url(&ProviderKind::Gitlab, "https://gitlab.example.test"), + "https://gitlab.example.test/-/user_settings/personal_access_tokens?name=git-sync&scopes=api" + ); + assert_eq!( + token_creation_url(&ProviderKind::Gitea, "gitea.example.test"), + "https://gitea.example.test/user/settings/applications" ); } } diff --git a/src/provider.rs b/src/provider.rs index ceb547a..8f419b2 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -70,6 +70,14 @@ impl<'a> ProviderClient<'a> { self.get(&url).map(|_| ()) } + pub fn detect_namespace_kind(&self, namespace: &str) -> Result> { + match self.site.provider { + ProviderKind::Github => self.github_detect_namespace_kind(namespace), + ProviderKind::Gitlab => self.gitlab_detect_namespace_kind(namespace), + ProviderKind::Gitea => self.gitea_detect_namespace_kind(namespace), + } + } + pub fn authenticated_clone_url(&self, clone_url: &str) -> Result { let mut url = Url::parse(clone_url) .or_else(|_| Url::parse(&format!("{}/{}", self.site.base_url, clone_url))) @@ -144,6 +152,16 @@ impl<'a> ProviderClient<'a> { self.post_json::(&url, &body).map(Into::into) } + fn github_detect_namespace_kind(&self, namespace: &str) -> Result> { + let url = format!("{}/users/{namespace}", self.site.api_base()); + let value: serde_json::Value = self.get_json(&url)?; + Ok(match value.get("type").and_then(|value| value.as_str()) { + Some("Organization") => Some(NamespaceKind::Org), + Some("User") => Some(NamespaceKind::User), + _ => None, + }) + } + fn gitlab_list_repos(&self, endpoint: &EndpointConfig) -> Result> { match endpoint.kind { NamespaceKind::User => { @@ -203,6 +221,25 @@ impl<'a> ProviderClient<'a> { self.get_json(&url) } + fn gitlab_detect_namespace_kind(&self, namespace: &str) -> Result> { + let group_url = format!("{}/groups/{}", self.site.api_base(), urlencoding(namespace)); + if self.get(&group_url).is_ok() { + return Ok(Some(NamespaceKind::Group)); + } + + let username = namespace.rsplit('/').next().unwrap_or(namespace); + let user_url = format!( + "{}/users?username={}", + self.site.api_base(), + urlencoding(username) + ); + let users: serde_json::Value = self.get_json(&user_url)?; + Ok(users + .as_array() + .is_some_and(|items| !items.is_empty()) + .then_some(NamespaceKind::User)) + } + fn gitea_list_repos(&self, endpoint: &EndpointConfig) -> Result> { match endpoint.kind { NamespaceKind::User => { @@ -252,6 +289,20 @@ impl<'a> ProviderClient<'a> { self.post_json::(&url, &body).map(Into::into) } + fn gitea_detect_namespace_kind(&self, namespace: &str) -> Result> { + let org_url = format!("{}/orgs/{namespace}", self.site.api_base()); + if self.get(&org_url).is_ok() { + return Ok(Some(NamespaceKind::Org)); + } + + let user_url = format!("{}/users/{namespace}", self.site.api_base()); + if self.get(&user_url).is_ok() { + return Ok(Some(NamespaceKind::User)); + } + + Ok(None) + } + fn paged_get(&self, first_url: &str) -> Result> where T: for<'de> Deserialize<'de>, @@ -555,6 +606,61 @@ mod tests { handle.join().unwrap(); } + #[test] + fn detect_namespace_kind_uses_authenticated_github_api() { + let (api_url, handle) = + one_request_server("200 OK", r#"{"type":"Organization"}"#, |request| { + assert!( + request.starts_with("GET /users/acme "), + "request was {request}" + ); + assert!( + request + .to_ascii_lowercase() + .contains("authorization: bearer secret"), + "request was {request}" + ); + }); + let site = SiteConfig { + api_url: Some(api_url), + ..site(ProviderKind::Github, None) + }; + + let kind = ProviderClient::new(&site) + .unwrap() + .detect_namespace_kind("acme") + .unwrap(); + assert_eq!(kind, Some(NamespaceKind::Org)); + handle.join().unwrap(); + } + + #[test] + fn detect_namespace_kind_uses_authenticated_gitea_api() { + let (api_url, handle) = one_request_server("200 OK", "{}", |request| { + assert!( + request.starts_with("GET /orgs/acme "), + "request was {request}" + ); + assert!( + request + .to_ascii_lowercase() + .contains("authorization: token secret"), + "request was {request}" + ); + }); + let site = SiteConfig { + api_url: Some(api_url), + ..site(ProviderKind::Gitea, None) + }; + + let kind = ProviderClient::new(&site) + .unwrap() + .detect_namespace_kind("acme") + .unwrap(); + assert_eq!(kind, Some(NamespaceKind::Org)); + handle.join().unwrap(); + } + fn site(provider: ProviderKind, git_username: Option) -> SiteConfig { SiteConfig { name: "site".to_string(), diff --git a/src/sync.rs b/src/sync.rs index e327bdd..7d29641 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -3,9 +3,10 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; +use console::style; use crate::config::{Config, EndpointConfig, MirrorConfig, default_work_dir, validate_config}; -use crate::git::{GitMirror, Redactor, RemoteSpec, safe_remote_name}; +use crate::git::{GitMirror, Redactor, RemoteSpec, is_disabled_repository_error, safe_remote_name}; use crate::provider::{EndpointRepo, ProviderClient, repos_by_name}; #[derive(Clone, Debug, Default)] @@ -43,22 +44,91 @@ pub fn sync_all(config: &Config, options: SyncOptions) -> Result<()> { .map(|site| site.token()) .collect::>>()?; let redactor = Redactor::new(tokens); + let mut failures = Vec::new(); for mirror in mirrors { - sync_group(config, mirror, &options, &work_dir, redactor.clone())?; + match sync_group(config, mirror, &options, &work_dir, redactor.clone()) { + Ok(mut group_failures) => failures.append(&mut group_failures), + Err(error) => { + let scope = format!("mirror group {}", mirror.name); + print_failure(&scope, &error); + failures.push(SyncFailure::new(scope, error)); + } + } + } + + if !failures.is_empty() { + print_failure_summary(&failures); + bail!("sync completed with {} failure(s)", failures.len()); } Ok(()) } +#[derive(Debug)] +struct SyncFailure { + scope: String, + error: String, +} + +impl SyncFailure { + fn new(scope: String, error: anyhow::Error) -> Self { + Self { + scope, + error: format_error(&error), + } + } +} + +fn print_failure(scope: &str, error: &anyhow::Error) { + println!( + " {} {} {}", + style("fail").red().bold(), + style(scope).cyan(), + style(error_headline(error)).dim() + ); +} + +fn print_failure_summary(failures: &[SyncFailure]) { + println!(); + println!( + "{} {}", + style("Failures").red().bold(), + style(format!("({})", failures.len())).dim() + ); + for (index, failure) in failures.iter().enumerate() { + println!(" {}. {}", index + 1, style(&failure.scope).cyan().bold()); + for line in failure.error.lines() { + println!(" {line}"); + } + } +} + +fn error_headline(error: &anyhow::Error) -> String { + format_error(error) + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or("unknown error") + .to_string() +} + +fn format_error(error: &anyhow::Error) -> String { + format!("{error:#}") +} + fn sync_group( config: &Config, mirror: &MirrorConfig, options: &SyncOptions, work_dir: &Path, redactor: Redactor, -) -> Result<()> { - println!("syncing mirror group {}", mirror.name); +) -> Result> { + println!(); + println!( + "{} {}", + style("Mirror group").cyan().bold(), + style(&mirror.name).bold() + ); let create_missing = options .create_missing_override .unwrap_or(mirror.create_missing); @@ -68,7 +138,11 @@ fn sync_group( for endpoint in &mirror.endpoints { let site = config.site(&endpoint.site).unwrap(); let client = ProviderClient::new(site)?; - println!("listing {}", endpoint.label()); + println!( + " {} {}", + style("list").cyan().bold(), + style(endpoint.label()).dim() + ); let repos = client .list_repos(endpoint) .with_context(|| format!("failed to list repos for {}", endpoint.label()))?; @@ -83,27 +157,16 @@ fn sync_group( let mut repos = repos_by_name(all_endpoint_repos); let repo_names = repos.keys().cloned().collect::>(); if repo_names.is_empty() { - println!("mirror group {} has no repositories", mirror.name); - return Ok(()); + println!( + " {} mirror group has no repositories", + style("skip").yellow().bold() + ); + return Ok(Vec::new()); } + let mut failures = Vec::new(); for repo_name in repo_names { let mut existing = repos.remove(&repo_name).unwrap_or_default(); - ensure_missing_repos( - config, - mirror, - &repo_name, - &mut existing, - create_missing, - options.dry_run, - )?; - if existing.len() < 2 { - println!( - "skipping {}: fewer than two endpoints have this repository", - repo_name - ); - continue; - } let context = RepoSyncContext { config, mirror, @@ -112,10 +175,16 @@ fn sync_group( dry_run: options.dry_run, allow_force, }; - sync_repo(&context, &repo_name, &existing)?; + if let Err(error) = sync_repo(&context, &repo_name, &mut existing, create_missing) + .with_context(|| format!("failed to sync repo {repo_name}")) + { + let scope = format!("{}/{}", mirror.name, repo_name); + print_failure(&scope, &error); + failures.push(SyncFailure::new(scope, error)); + } } - Ok(()) + Ok(failures) } fn ensure_missing_repos( @@ -138,14 +207,21 @@ fn ensure_missing_repos( } if !create_missing { println!( - "{} is missing on {}; creation disabled", - repo_name, - endpoint.label() + " {} {} missing on {} ({})", + style("skip").yellow().bold(), + style(repo_name).cyan(), + style(endpoint.label()).dim(), + style("creation disabled").dim() ); continue; } - println!("creating {} on {}", repo_name, endpoint.label()); + println!( + " {} {} {}", + style("create").green().bold(), + style(repo_name).cyan(), + style(format!("on {}", endpoint.label())).dim() + ); if dry_run { continue; } @@ -164,9 +240,10 @@ fn ensure_missing_repos( .with_context(|| format!("failed to create {} on {}", repo_name, endpoint.label()))?; if created.private != matches!(mirror.visibility, crate::config::Visibility::Private) { println!( - "created {} on {}, but provider reported a different visibility than requested", - repo_name, - endpoint.label() + " {} created {} on {}, but provider reported a different visibility than requested", + style("warn").yellow().bold(), + style(repo_name).cyan(), + style(endpoint.label()).dim() ); } existing.push(EndpointRepo { @@ -187,8 +264,97 @@ struct RepoSyncContext<'a> { allow_force: bool, } -fn sync_repo(context: &RepoSyncContext<'_>, repo_name: &str, repos: &[EndpointRepo]) -> Result<()> { - println!("syncing repo {}", repo_name); +fn sync_repo( + context: &RepoSyncContext<'_>, + repo_name: &str, + repos: &mut Vec, + create_missing: bool, +) -> Result<()> { + println!(); + println!( + "{} {}", + style("Repo").magenta().bold(), + style(repo_name).bold() + ); + if repos.is_empty() { + println!( + " {} {}", + style("skip").yellow().bold(), + style("repository not found on any endpoint").dim() + ); + return Ok(()); + } + + let path = context + .work_dir + .join(safe_remote_name(&context.mirror.name)) + .join(format!("{}.git", safe_remote_name(repo_name))); + let mirror_repo = GitMirror::open(path, context.redactor.clone(), context.dry_run)?; + + let initial_remotes = remote_specs(context, repos)?; + mirror_repo.configure_remotes(&initial_remotes)?; + for remote in &initial_remotes { + if let Err(error) = mirror_repo.fetch_remote(remote) { + if is_disabled_repository_error(&error) { + println!( + " {} {} {}", + style("skip").yellow().bold(), + style(repo_name).cyan(), + style(format!("provider blocked access on {}", remote.display)).dim() + ); + return Ok(()); + } + return Err(error).with_context(|| format!("failed to fetch {}", remote.display)); + } + } + + ensure_missing_repos( + context.config, + context.mirror, + repo_name, + repos, + create_missing, + context.dry_run, + )?; + + if repos.len() < 2 { + println!( + " {} {} {}", + style("skip").yellow().bold(), + style(repo_name).cyan(), + style("fewer than two endpoints have this repository").dim() + ); + return Ok(()); + } + + let remotes = remote_specs(context, repos)?; + mirror_repo.configure_remotes(&remotes)?; + let initial_remote_names = initial_remotes + .iter() + .map(|remote| remote.name.clone()) + .collect::>(); + for remote in remotes + .iter() + .filter(|remote| !initial_remote_names.contains(&remote.name)) + { + if let Err(error) = mirror_repo.fetch_remote(remote) { + if is_disabled_repository_error(&error) { + println!( + " {} {} {}", + style("skip").yellow().bold(), + style(repo_name).cyan(), + style(format!("provider blocked access on {}", remote.display)).dim() + ); + return Ok(()); + } + return Err(error).with_context(|| format!("failed to fetch {}", remote.display)); + } + } + + push_repo_refs(context, &mirror_repo, &remotes) +} + +fn remote_specs(context: &RepoSyncContext<'_>, repos: &[EndpointRepo]) -> Result> { let endpoint_map = context .mirror .endpoints @@ -214,17 +380,19 @@ fn sync_repo(context: &RepoSyncContext<'_>, repo_name: &str, repos: &[EndpointRe }); } - let path = context - .work_dir - .join(safe_remote_name(&context.mirror.name)) - .join(format!("{}.git", safe_remote_name(repo_name))); - let mirror_repo = GitMirror::open(path, context.redactor.clone(), context.dry_run)?; - mirror_repo.configure_remotes(&remotes)?; - for remote in &remotes { - mirror_repo.fetch_remote(remote)?; - } + Ok(remotes) +} - let (branches, conflicts) = mirror_repo.branch_decisions(&remotes, context.allow_force)?; +fn push_repo_refs( + context: &RepoSyncContext<'_>, + mirror_repo: &GitMirror, + remotes: &[RemoteSpec], +) -> Result<()> { + let (branches, conflicts) = mirror_repo.branch_decisions(remotes, context.allow_force)?; + let branches_to_push = branches + .into_iter() + .filter(|branch| !branch.target_remotes.is_empty()) + .collect::>(); for conflict in conflicts { let details = conflict .tips @@ -233,12 +401,19 @@ fn sync_repo(context: &RepoSyncContext<'_>, repo_name: &str, repos: &[EndpointRe .collect::>() .join(", "); println!( - "conflict in {}/{}: branch {} diverged across {}. Skipping that branch.", - context.mirror.name, repo_name, conflict.branch, details + " {} branch {} diverged across {} ({})", + style("conflict").yellow().bold(), + style(conflict.branch).cyan(), + details, + style("skipped").dim() ); } - let (tags, tag_conflicts) = mirror_repo.tag_decisions(&remotes)?; + let (tags, tag_conflicts) = mirror_repo.tag_decisions(remotes)?; + let tags_to_push = tags + .into_iter() + .filter(|tag| !tag.target_remotes.is_empty()) + .collect::>(); for conflict in tag_conflicts { let details = conflict .tips @@ -247,50 +422,74 @@ fn sync_repo(context: &RepoSyncContext<'_>, repo_name: &str, repos: &[EndpointRe .collect::>() .join(", "); println!( - "conflict in {}/{}: tag {} differs across {}. Skipping that tag.", - context.mirror.name, repo_name, conflict.tag, details + " {} tag {} differs across {} ({})", + style("conflict").yellow().bold(), + style(conflict.tag).cyan(), + details, + style("skipped").dim() ); } - if branches.is_empty() && tags.is_empty() { - println!("{} has no branches or tags to push", repo_name); + if branches_to_push.is_empty() && tags_to_push.is_empty() { + println!( + " {} branches and tags already match all endpoints", + style("up-to-date").green().bold() + ); return Ok(()); } - if !branches.is_empty() { - let branch_summary = branches - .iter() - .map(|branch| { - format!( - "{}@{} from {}", - branch.branch, - short_sha(&branch.sha), - branch.source_remotes.join("+") - ) - }) - .collect::>() - .join(", "); - println!("resolved branches for {}: {}", repo_name, branch_summary); - mirror_repo.push_branches(&remotes, &branches, context.allow_force)?; + if !branches_to_push.is_empty() { + print_branch_decisions(&branches_to_push); + mirror_repo.push_branches(remotes, &branches_to_push, context.allow_force)?; } - if !tags.is_empty() { - let tag_summary = tags - .iter() - .map(|tag| { - format!( - "{}@{} from {}", - tag.tag, - short_sha(&tag.sha), - tag.source_remotes.join("+") - ) - }) - .collect::>() - .join(", "); - println!("resolved tags for {}: {}", repo_name, tag_summary); - mirror_repo.push_tags(&remotes, &tags)?; + if !tags_to_push.is_empty() { + print_tag_decisions(&tags_to_push); + mirror_repo.push_tags(remotes, &tags_to_push)?; } Ok(()) } +fn print_branch_decisions(branches: &[crate::git::BranchDecision]) { + println!( + " {} {}", + style("branches").cyan().bold(), + style(format!("({})", branches.len())).dim() + ); + for branch in branches { + println!( + " {} {} {}", + style(&branch.branch).cyan(), + style(format!("@{}", short_sha(&branch.sha))).dim(), + style(format!( + "{} -> {}", + branch.source_remotes.join("+"), + branch.target_remotes.join("+") + )) + .dim() + ); + } +} + +fn print_tag_decisions(tags: &[crate::git::TagDecision]) { + println!( + " {} {}", + style("tags").cyan().bold(), + style(format!("({})", tags.len())).dim() + ); + for tag in tags { + println!( + " {} {} {}", + style(&tag.tag).cyan(), + style(format!("@{}", short_sha(&tag.sha))).dim(), + style(format!( + "{} -> {}", + tag.source_remotes.join("+"), + tag.target_remotes.join("+") + )) + .dim() + ); + } +} + fn short_sha(sha: &str) -> &str { sha.get(..12).unwrap_or(sha) }