[O] Better CLI wizard (#2)

This commit is contained in:
2026-05-03 18:49:25 -04:00
committed by GitHub
parent 0cdabb09e6
commit 3c0b3fc1e8
5 changed files with 1683 additions and 556 deletions
+16 -1
View File
@@ -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 `<base-url>/-/user_settings/personal_access_tokens?name=git-sync&scopes=api`, create the token, then copy it.
- Gitea: open `<base-url>/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.
+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)
}