[O] Better CLI wizard (#2)
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+106
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user