[O] Better CLI wizard (#2)

This commit is contained in:
2026-05-03 18:49:25 -04:00
committed by GitHub
parent 236ec5bb8b
commit 723822fbb2
5 changed files with 1683 additions and 556 deletions
+187 -18
View File
@@ -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<String>,
pub target_remotes: Vec<String>,
}
#[derive(Clone, Debug)]
@@ -30,6 +34,7 @@ pub struct TagDecision {
pub tag: String,
pub sha: String,
pub source_remotes: Vec<String>,
pub target_remotes: Vec<String>,
}
#[derive(Clone, Debug)]
@@ -53,7 +58,11 @@ impl GitMirror {
pub fn open(path: PathBuf, redactor: Redactor, dry_run: bool) -> Result<Self> {
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::<Vec<_>>();
for (branch, tips) in by_branch {
let unique = tips
@@ -114,34 +131,46 @@ impl GitMirror {
.map(|(_, sha)| sha.clone())
.collect::<BTreeSet<_>>();
if unique.len() == 1 {
let source_remotes = tips
.into_iter()
.map(|(remote, _)| remote)
.collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
for (tag, tips) in by_tag {
let unique = tips
@@ -174,10 +207,16 @@ impl GitMirror {
.map(|(_, sha)| sha.clone())
.collect::<BTreeSet<_>>();
if unique.len() == 1 {
let source_remotes = tips
.into_iter()
.map(|(remote, _)| remote)
.collect::<Vec<_>>();
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<String>,
stdout: impl Into<String>,
stderr: impl Into<String>,
) -> 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::<GitCommandError>())
.any(|error| is_disabled_repository_stderr(error.stderr()))
}
fn missing_remotes(all_remote_names: &[String], source_remotes: &[String]) -> Vec<String> {
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<String>) -> Self {
let secrets = secrets
@@ -390,7 +509,12 @@ where
.map(|arg| arg.as_ref().to_string())
.collect::<Vec<_>>();
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));
+1095 -457
View File
File diff suppressed because it is too large Load Diff
+106
View File
@@ -70,6 +70,14 @@ impl<'a> ProviderClient<'a> {
self.get(&url).map(|_| ())
}
pub fn detect_namespace_kind(&self, namespace: &str) -> Result<Option<NamespaceKind>> {
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<String> {
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::<GithubRepo>(&url, &body).map(Into::into)
}
fn github_detect_namespace_kind(&self, namespace: &str) -> Result<Option<NamespaceKind>> {
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<Vec<RemoteRepo>> {
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<Option<NamespaceKind>> {
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<Vec<RemoteRepo>> {
match endpoint.kind {
NamespaceKind::User => {
@@ -252,6 +289,20 @@ impl<'a> ProviderClient<'a> {
self.post_json::<GiteaRepo>(&url, &body).map(Into::into)
}
fn gitea_detect_namespace_kind(&self, namespace: &str) -> Result<Option<NamespaceKind>> {
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<T>(&self, first_url: &str) -> Result<Vec<T>>
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<String>) -> SiteConfig {
SiteConfig {
name: "site".to_string(),
+279 -80
View File
@@ -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::<Result<Vec<_>>>()?;
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<Vec<SyncFailure>> {
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::<BTreeSet<_>>();
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<EndpointRepo>,
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::<BTreeSet<_>>();
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<Vec<RemoteSpec>> {
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::<Vec<_>>();
for conflict in conflicts {
let details = conflict
.tips
@@ -233,12 +401,19 @@ fn sync_repo(context: &RepoSyncContext<'_>, repo_name: &str, repos: &[EndpointRe
.collect::<Vec<_>>()
.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::<Vec<_>>();
for conflict in tag_conflicts {
let details = conflict
.tips
@@ -247,50 +422,74 @@ fn sync_repo(context: &RepoSyncContext<'_>, repo_name: &str, repos: &[EndpointRe
.collect::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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)
}