[+] Create repo by codex
This commit is contained in:
+415
@@ -0,0 +1,415 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub sites: Vec<SiteConfig>,
|
||||
#[serde(default)]
|
||||
pub mirrors: Vec<MirrorConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct SiteConfig {
|
||||
pub name: String,
|
||||
pub provider: ProviderKind,
|
||||
pub base_url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_url: Option<String>,
|
||||
pub token: TokenConfig,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub git_username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderKind {
|
||||
Github,
|
||||
Gitlab,
|
||||
Gitea,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TokenConfig {
|
||||
Value(String),
|
||||
Env(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct MirrorConfig {
|
||||
pub name: String,
|
||||
pub endpoints: Vec<EndpointConfig>,
|
||||
#[serde(default = "default_true")]
|
||||
pub create_missing: bool,
|
||||
#[serde(default)]
|
||||
pub visibility: Visibility,
|
||||
#[serde(default)]
|
||||
pub allow_force: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
pub struct EndpointConfig {
|
||||
pub site: String,
|
||||
pub kind: NamespaceKind,
|
||||
pub namespace: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NamespaceKind {
|
||||
User,
|
||||
Org,
|
||||
Group,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Visibility {
|
||||
#[default]
|
||||
Private,
|
||||
Public,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let contents = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
toml::from_str(&contents).with_context(|| format!("failed to parse {}", path.display()))
|
||||
}
|
||||
|
||||
pub fn load_or_default(path: &Path) -> Result<Self> {
|
||||
if path.exists() {
|
||||
Self::load(path)
|
||||
} else {
|
||||
Ok(Self::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
let contents = toml::to_string_pretty(self)?;
|
||||
let mut file = fs::File::create(path)
|
||||
.with_context(|| format!("failed to create {}", path.display()))?;
|
||||
file.write_all(contents.as_bytes())
|
||||
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||
protect_file(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn site(&self, name: &str) -> Option<&SiteConfig> {
|
||||
self.sites.iter().find(|site| site.name == name)
|
||||
}
|
||||
|
||||
pub fn upsert_site(&mut self, site: SiteConfig) {
|
||||
if let Some(existing) = self
|
||||
.sites
|
||||
.iter_mut()
|
||||
.find(|existing| existing.name == site.name)
|
||||
{
|
||||
*existing = site;
|
||||
} else {
|
||||
self.sites.push(site);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_site(&mut self, name: &str) -> Result<()> {
|
||||
if !self.sites.iter().any(|site| site.name == name) {
|
||||
bail!("site '{name}' does not exist");
|
||||
}
|
||||
for mirror in &self.mirrors {
|
||||
if mirror
|
||||
.endpoints
|
||||
.iter()
|
||||
.any(|endpoint| endpoint.site == name)
|
||||
{
|
||||
bail!("site '{name}' is still used by mirror '{}'", mirror.name);
|
||||
}
|
||||
}
|
||||
self.sites.retain(|site| site.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_mirror(&mut self, mirror: MirrorConfig) {
|
||||
if let Some(existing) = self
|
||||
.mirrors
|
||||
.iter_mut()
|
||||
.find(|existing| existing.name == mirror.name)
|
||||
{
|
||||
*existing = mirror;
|
||||
} else {
|
||||
self.mirrors.push(mirror);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_mirror(&mut self, name: &str) -> Result<()> {
|
||||
let old_len = self.mirrors.len();
|
||||
self.mirrors.retain(|mirror| mirror.name != name);
|
||||
if self.mirrors.len() == old_len {
|
||||
bail!("mirror '{name}' does not exist");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SiteConfig {
|
||||
pub fn token(&self) -> Result<String> {
|
||||
match &self.token {
|
||||
TokenConfig::Value(value) => Ok(value.clone()),
|
||||
TokenConfig::Env(name) => {
|
||||
env::var(name).with_context(|| format!("environment variable {name} is not set"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_base(&self) -> String {
|
||||
if let Some(api_url) = &self.api_url {
|
||||
return trim_end(api_url).to_string();
|
||||
}
|
||||
|
||||
match self.provider {
|
||||
ProviderKind::Github => {
|
||||
if self.base_url.trim_end_matches('/') == "https://github.com" {
|
||||
"https://api.github.com".to_string()
|
||||
} else {
|
||||
format!("{}/api/v3", trim_end(&self.base_url))
|
||||
}
|
||||
}
|
||||
ProviderKind::Gitlab => format!("{}/api/v4", trim_end(&self.base_url)),
|
||||
ProviderKind::Gitea => format!("{}/api/v1", trim_end(&self.base_url)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EndpointConfig {
|
||||
pub fn label(&self) -> String {
|
||||
format!("{}:{}:{:?}", self.site, self.namespace, self.kind)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> PathBuf {
|
||||
ProjectDirs::from("dev", "git-sync", "git-sync")
|
||||
.map(|dirs| dirs.config_dir().join("config.toml"))
|
||||
.unwrap_or_else(|| PathBuf::from("git-sync.toml"))
|
||||
}
|
||||
|
||||
pub fn default_work_dir() -> PathBuf {
|
||||
ProjectDirs::from("dev", "git-sync", "git-sync")
|
||||
.map(|dirs| dirs.cache_dir().join("mirrors"))
|
||||
.unwrap_or_else(|| PathBuf::from(".git-sync-cache"))
|
||||
}
|
||||
|
||||
fn trim_end(value: &str) -> &str {
|
||||
value.trim_end_matches('/')
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn protect_file(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let permissions = fs::Permissions::from_mode(0o600);
|
||||
fs::set_permissions(path, permissions)
|
||||
.with_context(|| format!("failed to set permissions on {}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn protect_file(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_config(config: &Config) -> Result<()> {
|
||||
if config.sites.is_empty() {
|
||||
bail!("no sites configured");
|
||||
}
|
||||
if config.mirrors.is_empty() {
|
||||
bail!("no mirror groups configured");
|
||||
}
|
||||
for mirror in &config.mirrors {
|
||||
if mirror.endpoints.len() < 2 {
|
||||
bail!(
|
||||
"mirror '{}' must contain at least two endpoints",
|
||||
mirror.name
|
||||
);
|
||||
}
|
||||
for endpoint in &mirror.endpoints {
|
||||
config.site(&endpoint.site).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"mirror '{}' references unknown site '{}'",
|
||||
mirror.name,
|
||||
endpoint.site
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_token_forms() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[[sites]]
|
||||
name = "github"
|
||||
provider = "github"
|
||||
base_url = "https://github.com"
|
||||
token = { env = "GITHUB_TOKEN" }
|
||||
|
||||
[[mirrors]]
|
||||
name = "personal"
|
||||
create_missing = true
|
||||
visibility = "private"
|
||||
allow_force = false
|
||||
|
||||
[[mirrors.endpoints]]
|
||||
site = "github"
|
||||
kind = "user"
|
||||
namespace = "alice"
|
||||
|
||||
[[mirrors.endpoints]]
|
||||
site = "github"
|
||||
kind = "org"
|
||||
namespace = "example"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.sites.len(), 1);
|
||||
assert_eq!(config.mirrors[0].endpoints.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
|
||||
let config = Config {
|
||||
sites: vec![site("github", ProviderKind::Github)],
|
||||
mirrors: vec![MirrorConfig {
|
||||
name: "broken".to_string(),
|
||||
endpoints: vec![EndpointConfig {
|
||||
site: "github".to_string(),
|
||||
kind: NamespaceKind::User,
|
||||
namespace: "alice".to_string(),
|
||||
}],
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
}],
|
||||
};
|
||||
let err = validate_config(&config).unwrap_err().to_string();
|
||||
assert!(err.contains("at least two endpoints"));
|
||||
|
||||
let config = Config {
|
||||
sites: vec![site("github", ProviderKind::Github)],
|
||||
mirrors: vec![MirrorConfig {
|
||||
name: "broken".to_string(),
|
||||
endpoints: vec![
|
||||
EndpointConfig {
|
||||
site: "github".to_string(),
|
||||
kind: NamespaceKind::User,
|
||||
namespace: "alice".to_string(),
|
||||
},
|
||||
EndpointConfig {
|
||||
site: "missing".to_string(),
|
||||
kind: NamespaceKind::User,
|
||||
namespace: "alice".to_string(),
|
||||
},
|
||||
],
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
}],
|
||||
};
|
||||
let err = validate_config(&config).unwrap_err().to_string();
|
||||
assert!(err.contains("unknown site 'missing'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_referenced_site_is_rejected() {
|
||||
let mut config = Config {
|
||||
sites: vec![
|
||||
site("github", ProviderKind::Github),
|
||||
site("gitea", ProviderKind::Gitea),
|
||||
],
|
||||
mirrors: vec![MirrorConfig {
|
||||
name: "personal".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 err = config.remove_site("github").unwrap_err().to_string();
|
||||
assert!(err.contains("still used by mirror 'personal'"));
|
||||
assert!(config.site("github").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_base_defaults_match_providers() {
|
||||
assert_eq!(
|
||||
site("github", ProviderKind::Github).api_base(),
|
||||
"https://api.github.com"
|
||||
);
|
||||
assert_eq!(
|
||||
SiteConfig {
|
||||
base_url: "https://github.example.test/".to_string(),
|
||||
..site("github-enterprise", ProviderKind::Github)
|
||||
}
|
||||
.api_base(),
|
||||
"https://github.example.test/api/v3"
|
||||
);
|
||||
assert_eq!(
|
||||
SiteConfig {
|
||||
base_url: "https://gitlab.example.test".to_string(),
|
||||
..site("gitlab", ProviderKind::Gitlab)
|
||||
}
|
||||
.api_base(),
|
||||
"https://gitlab.example.test/api/v4"
|
||||
);
|
||||
assert_eq!(
|
||||
SiteConfig {
|
||||
base_url: "https://gitea.example.test".to_string(),
|
||||
..site("gitea", ProviderKind::Gitea)
|
||||
}
|
||||
.api_base(),
|
||||
"https://gitea.example.test/api/v1"
|
||||
);
|
||||
}
|
||||
|
||||
fn site(name: &str, provider: ProviderKind) -> SiteConfig {
|
||||
SiteConfig {
|
||||
name: name.to_string(),
|
||||
provider,
|
||||
base_url: "https://github.com".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("token".to_string()),
|
||||
git_username: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
+758
@@ -0,0 +1,758 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteSpec {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub display: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BranchDecision {
|
||||
pub branch: String,
|
||||
pub sha: String,
|
||||
pub source_remotes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BranchConflict {
|
||||
pub branch: String,
|
||||
pub tips: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TagDecision {
|
||||
pub tag: String,
|
||||
pub sha: String,
|
||||
pub source_remotes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TagConflict {
|
||||
pub tag: String,
|
||||
pub tips: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub struct GitMirror {
|
||||
path: PathBuf,
|
||||
redactor: Redactor,
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Redactor {
|
||||
secrets: Vec<String>,
|
||||
}
|
||||
|
||||
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());
|
||||
return Ok(Self {
|
||||
path,
|
||||
redactor,
|
||||
dry_run,
|
||||
});
|
||||
}
|
||||
fs::create_dir_all(&path)?;
|
||||
run_plain("git", ["init", "--bare"], Some(&path), &redactor, dry_run)
|
||||
.with_context(|| format!("failed to initialize {}", path.display()))?;
|
||||
}
|
||||
Ok(Self {
|
||||
path,
|
||||
redactor,
|
||||
dry_run,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn configure_remotes(&self, remotes: &[RemoteSpec]) -> Result<()> {
|
||||
for remote in remotes {
|
||||
let existing = self.remote_url(&remote.name)?;
|
||||
match existing {
|
||||
Some(_) => self.run(["remote", "set-url", &remote.name, &remote.url])?,
|
||||
None => self.run(["remote", "add", &remote.name, &remote.url])?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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);
|
||||
self.run(["fetch", "--prune", &remote.name, &branch_refspec])?;
|
||||
self.run(["fetch", "--prune", &remote.name, &tag_refspec])
|
||||
}
|
||||
|
||||
pub fn branch_decisions(
|
||||
&self,
|
||||
remotes: &[RemoteSpec],
|
||||
allow_force: bool,
|
||||
) -> Result<(Vec<BranchDecision>, Vec<BranchConflict>)> {
|
||||
let mut by_branch: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
|
||||
for remote in remotes {
|
||||
for (branch, sha) in self.remote_branches(&remote.name)? {
|
||||
by_branch
|
||||
.entry(branch)
|
||||
.or_default()
|
||||
.push((remote.name.clone(), sha));
|
||||
}
|
||||
}
|
||||
|
||||
let mut decisions = Vec::new();
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
for (branch, tips) in by_branch {
|
||||
let unique = tips
|
||||
.iter()
|
||||
.map(|(_, sha)| sha.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if unique.len() == 1 {
|
||||
decisions.push(BranchDecision {
|
||||
branch,
|
||||
sha: unique.into_iter().next().unwrap(),
|
||||
source_remotes: tips.into_iter().map(|(remote, _)| remote).collect(),
|
||||
});
|
||||
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();
|
||||
decisions.push(BranchDecision {
|
||||
branch,
|
||||
sha: winner,
|
||||
source_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();
|
||||
decisions.push(BranchDecision {
|
||||
branch,
|
||||
sha: winner,
|
||||
source_remotes,
|
||||
});
|
||||
} else {
|
||||
conflicts.push(BranchConflict { branch, tips });
|
||||
}
|
||||
}
|
||||
|
||||
Ok((decisions, conflicts))
|
||||
}
|
||||
|
||||
pub fn tag_decisions(
|
||||
&self,
|
||||
remotes: &[RemoteSpec],
|
||||
) -> Result<(Vec<TagDecision>, Vec<TagConflict>)> {
|
||||
let mut by_tag: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
|
||||
for remote in remotes {
|
||||
for (tag, sha) in self.remote_tags(&remote.name)? {
|
||||
by_tag
|
||||
.entry(tag)
|
||||
.or_default()
|
||||
.push((remote.name.clone(), sha));
|
||||
}
|
||||
}
|
||||
|
||||
let mut decisions = Vec::new();
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
for (tag, tips) in by_tag {
|
||||
let unique = tips
|
||||
.iter()
|
||||
.map(|(_, sha)| sha.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if unique.len() == 1 {
|
||||
decisions.push(TagDecision {
|
||||
tag,
|
||||
sha: unique.into_iter().next().unwrap(),
|
||||
source_remotes: tips.into_iter().map(|(remote, _)| remote).collect(),
|
||||
});
|
||||
} else {
|
||||
conflicts.push(TagConflict { tag, tips });
|
||||
}
|
||||
}
|
||||
|
||||
Ok((decisions, conflicts))
|
||||
}
|
||||
|
||||
pub fn push_branches(
|
||||
&self,
|
||||
remotes: &[RemoteSpec],
|
||||
branches: &[BranchDecision],
|
||||
force: bool,
|
||||
) -> Result<()> {
|
||||
for remote in remotes {
|
||||
for branch in branches {
|
||||
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);
|
||||
self.run(["push", &remote.name, &refspec])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_tags(&self, remotes: &[RemoteSpec], tags: &[TagDecision]) -> Result<()> {
|
||||
for remote in remotes {
|
||||
for tag in tags {
|
||||
let refspec = format!("{}:refs/tags/{}", tag.sha, tag.tag);
|
||||
println!("pushing tag {} to {}", tag.tag, remote.display);
|
||||
self.run(["push", &remote.name, &refspec])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remote_url(&self, name: &str) -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.arg("--git-dir")
|
||||
.arg(&self.path)
|
||||
.args(["remote", "get-url", name])
|
||||
.output()
|
||||
.with_context(|| "failed to run git remote get-url")?;
|
||||
if output.status.success() {
|
||||
Ok(Some(
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_branches(&self, remote: &str) -> Result<Vec<(String, String)>> {
|
||||
let prefix = format!("refs/remotes/{remote}/");
|
||||
let output = self.output(["for-each-ref", "--format=%(refname) %(objectname)", &prefix])?;
|
||||
let mut branches = Vec::new();
|
||||
for line in output.lines() {
|
||||
let Some((refname, sha)) = line.split_once(' ') else {
|
||||
continue;
|
||||
};
|
||||
let Some(branch) = refname.strip_prefix(&prefix) else {
|
||||
continue;
|
||||
};
|
||||
if branch == "HEAD" {
|
||||
continue;
|
||||
}
|
||||
branches.push((branch.to_string(), sha.to_string()));
|
||||
}
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
fn remote_tags(&self, remote: &str) -> Result<Vec<(String, String)>> {
|
||||
let prefix = format!("refs/remote-tags/{remote}/");
|
||||
let output = self.output(["for-each-ref", "--format=%(refname) %(objectname)", &prefix])?;
|
||||
let mut tags = Vec::new();
|
||||
for line in output.lines() {
|
||||
let Some((refname, sha)) = line.split_once(' ') else {
|
||||
continue;
|
||||
};
|
||||
let Some(tag) = refname.strip_prefix(&prefix) else {
|
||||
continue;
|
||||
};
|
||||
tags.push((tag.to_string(), sha.to_string()));
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
fn fast_forward_winner<'a>(
|
||||
&self,
|
||||
shas: impl Iterator<Item = &'a String> + Clone,
|
||||
) -> Result<Option<String>> {
|
||||
for candidate in shas.clone() {
|
||||
let mut is_descendant = true;
|
||||
for other in shas.clone() {
|
||||
if candidate == other {
|
||||
continue;
|
||||
}
|
||||
if !self.is_ancestor(other, candidate)? {
|
||||
is_descendant = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if is_descendant {
|
||||
return Ok(Some(candidate.clone()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn newest_commit<'a>(&self, shas: impl Iterator<Item = &'a String>) -> Result<String> {
|
||||
let mut newest: Option<(i64, String)> = None;
|
||||
for sha in shas {
|
||||
let timestamp = self
|
||||
.output(["show", "-s", "--format=%ct", sha])?
|
||||
.trim()
|
||||
.parse::<i64>()?;
|
||||
match &newest {
|
||||
Some((old, _)) if *old >= timestamp => {}
|
||||
_ => newest = Some((timestamp, sha.clone())),
|
||||
}
|
||||
}
|
||||
newest
|
||||
.map(|(_, sha)| sha)
|
||||
.context("no commits found while choosing force winner")
|
||||
}
|
||||
|
||||
fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
|
||||
let status = Command::new("git")
|
||||
.arg("--git-dir")
|
||||
.arg(&self.path)
|
||||
.args(["merge-base", "--is-ancestor", ancestor, descendant])
|
||||
.status()
|
||||
.with_context(|| "failed to run git merge-base")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(true),
|
||||
Some(1) => Ok(false),
|
||||
_ => bail!("git merge-base failed for {ancestor} and {descendant}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run<const N: usize>(&self, args: [&str; N]) -> Result<()> {
|
||||
run_plain(
|
||||
"git",
|
||||
std::iter::once("--git-dir")
|
||||
.chain(std::iter::once(self.path.to_str().unwrap()))
|
||||
.chain(args),
|
||||
None,
|
||||
&self.redactor,
|
||||
self.dry_run,
|
||||
)
|
||||
}
|
||||
|
||||
fn output<const N: usize>(&self, args: [&str; N]) -> Result<String> {
|
||||
if self.dry_run {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let output = Command::new("git")
|
||||
.arg("--git-dir")
|
||||
.arg(&self.path)
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| "failed to run git")?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
bail!(
|
||||
"git failed: {}",
|
||||
self.redactor
|
||||
.redact(&String::from_utf8_lossy(&output.stderr))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Redactor {
|
||||
pub fn new(secrets: Vec<String>) -> Self {
|
||||
let secrets = secrets
|
||||
.into_iter()
|
||||
.filter(|secret| !secret.is_empty())
|
||||
.collect();
|
||||
Self { secrets }
|
||||
}
|
||||
|
||||
pub fn redact(&self, value: &str) -> String {
|
||||
let mut redacted = value.to_string();
|
||||
for secret in &self.secrets {
|
||||
redacted = redacted.replace(secret, "<redacted>");
|
||||
}
|
||||
redacted
|
||||
}
|
||||
}
|
||||
|
||||
fn run_plain<I, S>(
|
||||
program: &str,
|
||||
args: I,
|
||||
current_dir: Option<&Path>,
|
||||
redactor: &Redactor,
|
||||
dry_run: bool,
|
||||
) -> Result<()>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let args = args
|
||||
.into_iter()
|
||||
.map(|arg| arg.as_ref().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if dry_run {
|
||||
println!("dry-run: {} {}", program, redactor.redact(&args.join(" ")));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut command = Command::new(program);
|
||||
command.args(&args);
|
||||
if let Some(current_dir) = current_dir {
|
||||
command.current_dir(current_dir);
|
||||
}
|
||||
let output = command
|
||||
.output()
|
||||
.with_context(|| format!("failed to run {program}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} 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}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn safe_remote_name(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn remote_names_are_git_friendly() {
|
||||
assert_eq!(
|
||||
safe_remote_name("github:alice/project"),
|
||||
"github_alice_project"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_all_secrets() {
|
||||
let redactor = Redactor::new(vec!["secret".to_string()]);
|
||||
assert_eq!(
|
||||
redactor.redact("https://secret@example.test"),
|
||||
"https://<redacted>@example.test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_decisions_choose_fast_forward_tip() {
|
||||
let fixture = GitFixture::new();
|
||||
let base = fixture.commit("base", "base", 1_700_000_000);
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.push_head(&fixture.remote_b, "main");
|
||||
let newer = fixture.commit("newer", "newer", 1_700_000_100);
|
||||
fixture.push_head(&fixture.remote_a, "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.sha, newer);
|
||||
assert_eq!(main.source_remotes, vec!["a".to_string()]);
|
||||
assert_ne!(main.sha, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_decisions_report_divergent_tips_without_force() {
|
||||
let fixture = GitFixture::new();
|
||||
let base = fixture.commit("base", "base", 1_700_000_000);
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.push_head(&fixture.remote_b, "main");
|
||||
|
||||
let a_tip = fixture.commit("a", "a", 1_700_000_100);
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.reset_hard(&base);
|
||||
let b_tip = fixture.commit("b", "b", 1_700_000_200);
|
||||
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!(decisions.is_empty());
|
||||
assert_eq!(conflicts.len(), 1);
|
||||
assert_eq!(conflicts[0].branch, "main");
|
||||
assert!(conflicts[0].tips.iter().any(|(_, sha)| sha == &a_tip));
|
||||
assert!(conflicts[0].tips.iter().any(|(_, sha)| sha == &b_tip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_decisions_force_selects_newest_divergent_tip() {
|
||||
let fixture = GitFixture::new();
|
||||
let base = fixture.commit("base", "base", 1_700_000_000);
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.push_head(&fixture.remote_b, "main");
|
||||
|
||||
let older = fixture.commit("older", "older", 1_700_000_100);
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.reset_hard(&base);
|
||||
let newer = fixture.commit("newer", "newer", 1_700_000_200);
|
||||
fixture.push_head(&fixture.remote_b, "main");
|
||||
|
||||
let mirror = fixture.mirror();
|
||||
fixture.fetch_all(&mirror);
|
||||
let (decisions, conflicts) = mirror.branch_decisions(&fixture.remotes(), true).unwrap();
|
||||
|
||||
assert!(conflicts.is_empty());
|
||||
let main = find_branch(&decisions, "main");
|
||||
assert_eq!(main.sha, newer);
|
||||
assert_ne!(main.sha, older);
|
||||
assert_eq!(main.source_remotes, vec!["b".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_branches_creates_missing_branch_on_other_remotes() {
|
||||
let fixture = GitFixture::new();
|
||||
let expected = fixture.commit("base", "base", 1_700_000_000);
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
|
||||
let mirror = fixture.mirror();
|
||||
fixture.fetch_all(&mirror);
|
||||
let (decisions, conflicts) = mirror.branch_decisions(&fixture.remotes(), false).unwrap();
|
||||
assert!(conflicts.is_empty());
|
||||
mirror
|
||||
.push_branches(&fixture.remotes(), &decisions, false)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fixture.remote_ref(&fixture.remote_b, "refs/heads/main"),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_decisions_mirror_matching_or_missing_tags_and_skip_divergent_tags() {
|
||||
let fixture = GitFixture::new();
|
||||
let base = fixture.commit("base", "base", 1_700_000_000);
|
||||
fixture.tag("v1");
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.push_head(&fixture.remote_b, "main");
|
||||
fixture.push_tag(&fixture.remote_a, "v1");
|
||||
fixture.push_tag(&fixture.remote_b, "v1");
|
||||
|
||||
let a_tip = fixture.commit("a", "a", 1_700_000_100);
|
||||
fixture.tag("release");
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.push_tag(&fixture.remote_a, "release");
|
||||
|
||||
fixture.delete_tag("release");
|
||||
fixture.reset_hard(&base);
|
||||
let b_tip = fixture.commit("b", "b", 1_700_000_200);
|
||||
fixture.tag("release");
|
||||
fixture.push_head(&fixture.remote_b, "main");
|
||||
fixture.push_tag(&fixture.remote_b, "release");
|
||||
|
||||
fixture.delete_tag("missing-on-b");
|
||||
fixture.reset_hard(&a_tip);
|
||||
fixture.tag("missing-on-b");
|
||||
fixture.push_tag(&fixture.remote_a, "missing-on-b");
|
||||
|
||||
let mirror = fixture.mirror();
|
||||
fixture.fetch_all(&mirror);
|
||||
let (tags, conflicts) = mirror.tag_decisions(&fixture.remotes()).unwrap();
|
||||
|
||||
assert_eq!(find_tag(&tags, "v1").sha, base);
|
||||
assert_eq!(find_tag(&tags, "missing-on-b").sha, a_tip);
|
||||
assert_eq!(conflicts.len(), 1);
|
||||
assert_eq!(conflicts[0].tag, "release");
|
||||
assert!(conflicts[0].tips.iter().any(|(_, sha)| sha == &a_tip));
|
||||
assert!(conflicts[0].tips.iter().any(|(_, sha)| sha == &b_tip));
|
||||
|
||||
mirror.push_tags(&fixture.remotes(), &tags).unwrap();
|
||||
assert_eq!(
|
||||
fixture.remote_ref(&fixture.remote_b, "refs/tags/missing-on-b"),
|
||||
a_tip
|
||||
);
|
||||
}
|
||||
|
||||
fn find_branch<'a>(decisions: &'a [BranchDecision], name: &str) -> &'a BranchDecision {
|
||||
decisions
|
||||
.iter()
|
||||
.find(|decision| decision.branch == name)
|
||||
.unwrap_or_else(|| panic!("missing branch decision for {name}"))
|
||||
}
|
||||
|
||||
fn find_tag<'a>(decisions: &'a [TagDecision], name: &str) -> &'a TagDecision {
|
||||
decisions
|
||||
.iter()
|
||||
.find(|decision| decision.tag == name)
|
||||
.unwrap_or_else(|| panic!("missing tag decision for {name}"))
|
||||
}
|
||||
|
||||
struct GitFixture {
|
||||
_temp: TempDir,
|
||||
work: PathBuf,
|
||||
mirror_path: PathBuf,
|
||||
remote_a: PathBuf,
|
||||
remote_b: PathBuf,
|
||||
}
|
||||
|
||||
impl GitFixture {
|
||||
fn new() -> Self {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let work = temp.path().join("work");
|
||||
let mirror_path = temp.path().join("mirror.git");
|
||||
let remote_a = temp.path().join("a.git");
|
||||
let remote_b = temp.path().join("b.git");
|
||||
git(None, ["init", "--bare", remote_a.to_str().unwrap()]);
|
||||
git(None, ["init", "--bare", remote_b.to_str().unwrap()]);
|
||||
fs::create_dir_all(&work).unwrap();
|
||||
git(Some(&work), ["init"]);
|
||||
git(Some(&work), ["config", "user.email", "test@example.test"]);
|
||||
git(Some(&work), ["config", "user.name", "Test User"]);
|
||||
git(Some(&work), ["checkout", "-b", "main"]);
|
||||
|
||||
Self {
|
||||
_temp: temp,
|
||||
work,
|
||||
mirror_path,
|
||||
remote_a,
|
||||
remote_b,
|
||||
}
|
||||
}
|
||||
|
||||
fn mirror(&self) -> GitMirror {
|
||||
let mirror =
|
||||
GitMirror::open(self.mirror_path.clone(), Redactor::new(Vec::new()), false)
|
||||
.unwrap();
|
||||
mirror.configure_remotes(&self.remotes()).unwrap();
|
||||
mirror
|
||||
}
|
||||
|
||||
fn remotes(&self) -> Vec<RemoteSpec> {
|
||||
vec![
|
||||
RemoteSpec {
|
||||
name: "a".to_string(),
|
||||
url: self.remote_a.to_string_lossy().to_string(),
|
||||
display: "remote a".to_string(),
|
||||
},
|
||||
RemoteSpec {
|
||||
name: "b".to_string(),
|
||||
url: self.remote_b.to_string_lossy().to_string(),
|
||||
display: "remote b".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn fetch_all(&self, mirror: &GitMirror) {
|
||||
for remote in self.remotes() {
|
||||
mirror.fetch_remote(&remote).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn commit(&self, message: &str, contents: &str, timestamp: i64) -> String {
|
||||
let path = self.work.join("file.txt");
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.unwrap();
|
||||
writeln!(file, "{contents}").unwrap();
|
||||
git(Some(&self.work), ["add", "file.txt"]);
|
||||
|
||||
let date = format!("@{timestamp} +0000");
|
||||
let output = Command::new("git")
|
||||
.current_dir(&self.work)
|
||||
.env("GIT_AUTHOR_DATE", &date)
|
||||
.env("GIT_COMMITTER_DATE", &date)
|
||||
.args(["commit", "-m", message])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_success(&output, "git commit");
|
||||
self.head()
|
||||
}
|
||||
|
||||
fn head(&self) -> String {
|
||||
git_output(Some(&self.work), ["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
fn reset_hard(&self, sha: &str) {
|
||||
git(Some(&self.work), ["reset", "--hard", sha]);
|
||||
}
|
||||
|
||||
fn push_head(&self, remote: &Path, branch: &str) {
|
||||
let refspec = format!("HEAD:refs/heads/{branch}");
|
||||
git(
|
||||
Some(&self.work),
|
||||
["push", remote.to_str().unwrap(), &refspec],
|
||||
);
|
||||
}
|
||||
|
||||
fn tag(&self, name: &str) {
|
||||
git(Some(&self.work), ["tag", name]);
|
||||
}
|
||||
|
||||
fn delete_tag(&self, name: &str) {
|
||||
let _ = Command::new("git")
|
||||
.current_dir(&self.work)
|
||||
.args(["tag", "-d", name])
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn push_tag(&self, remote: &Path, tag: &str) {
|
||||
let refspec = format!("refs/tags/{tag}:refs/tags/{tag}");
|
||||
git(
|
||||
Some(&self.work),
|
||||
["push", remote.to_str().unwrap(), &refspec],
|
||||
);
|
||||
}
|
||||
|
||||
fn remote_ref(&self, remote: &Path, reference: &str) -> String {
|
||||
git_output(
|
||||
None,
|
||||
[
|
||||
"--git-dir",
|
||||
remote.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
reference,
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn git<const N: usize>(current_dir: Option<&Path>, args: [&str; N]) {
|
||||
let output = git_command(current_dir, args).output().unwrap();
|
||||
assert_success(&output, "git");
|
||||
}
|
||||
|
||||
fn git_output<const N: usize>(current_dir: Option<&Path>, args: [&str; N]) -> String {
|
||||
let output = git_command(current_dir, args).output().unwrap();
|
||||
assert_success(&output, "git output");
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
fn git_command<const N: usize>(current_dir: Option<&Path>, args: [&str; N]) -> Command {
|
||||
let mut command = Command::new("git");
|
||||
command.args(args);
|
||||
if let Some(current_dir) = current_dir {
|
||||
command.current_dir(current_dir);
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
fn assert_success(output: &std::process::Output, label: &str) {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{label} failed\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
mod config;
|
||||
mod git;
|
||||
mod provider;
|
||||
mod sync;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
|
||||
use crate::config::{
|
||||
Config, EndpointConfig, NamespaceKind, ProviderKind, SiteConfig, TokenConfig, Visibility,
|
||||
default_config_path,
|
||||
};
|
||||
use crate::sync::{SyncOptions, sync_all};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "git-sync")]
|
||||
#[command(about = "Mirror repositories between Git hosting providers")]
|
||||
struct Cli {
|
||||
#[arg(long, global = true, value_name = "PATH")]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Command {
|
||||
#[command(subcommand)]
|
||||
Config(ConfigCommand),
|
||||
Sync(SyncCommand),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ConfigCommand {
|
||||
Init,
|
||||
#[command(subcommand)]
|
||||
Site(SiteCommand),
|
||||
#[command(subcommand)]
|
||||
Mirror(MirrorCommand),
|
||||
Show,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SiteCommand {
|
||||
Add(SiteAddCommand),
|
||||
Remove(NameCommand),
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum MirrorCommand {
|
||||
Add(MirrorAddCommand),
|
||||
Remove(NameCommand),
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct NameCommand {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SiteAddCommand {
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
provider: ProviderArg,
|
||||
#[arg(long, value_name = "URL")]
|
||||
base_url: String,
|
||||
#[arg(long, value_name = "URL")]
|
||||
api_url: Option<String>,
|
||||
#[arg(long, conflicts_with = "token_env")]
|
||||
token: Option<String>,
|
||||
#[arg(long, value_name = "ENV", conflicts_with = "token")]
|
||||
token_env: Option<String>,
|
||||
#[arg(long)]
|
||||
git_username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct MirrorAddCommand {
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
#[arg(long = "endpoint", required = true, action = clap::ArgAction::Append, value_name = "SITE:KIND:NAMESPACE")]
|
||||
endpoints: Vec<String>,
|
||||
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
|
||||
create_missing: bool,
|
||||
#[arg(long, default_value_t = VisibilityArg::Private)]
|
||||
visibility: VisibilityArg,
|
||||
#[arg(long, default_value_t = false)]
|
||||
allow_force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SyncCommand {
|
||||
#[arg(long, value_name = "NAME")]
|
||||
group: Option<String>,
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
#[arg(long)]
|
||||
no_create: bool,
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
work_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, ValueEnum)]
|
||||
enum ProviderArg {
|
||||
Github,
|
||||
Gitlab,
|
||||
Gitea,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, ValueEnum)]
|
||||
enum VisibilityArg {
|
||||
Private,
|
||||
Public,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VisibilityArg {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Private => write!(f, "private"),
|
||||
Self::Public => write!(f, "public"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let config_path = cli.config.unwrap_or_else(default_config_path);
|
||||
|
||||
match cli.command {
|
||||
Command::Config(command) => handle_config(command, config_path),
|
||||
Command::Sync(command) => {
|
||||
let config = Config::load(&config_path)
|
||||
.with_context(|| format!("failed to load config at {}", config_path.display()))?;
|
||||
sync_all(
|
||||
&config,
|
||||
SyncOptions {
|
||||
group: command.group,
|
||||
dry_run: command.dry_run,
|
||||
create_missing_override: command.no_create.then_some(false),
|
||||
force_override: command.force.then_some(true),
|
||||
work_dir: command.work_dir,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_config(command: ConfigCommand, path: PathBuf) -> Result<()> {
|
||||
match command {
|
||||
ConfigCommand::Init => {
|
||||
if path.exists() {
|
||||
bail!("config already exists at {}", path.display());
|
||||
}
|
||||
let config = Config::default();
|
||||
config.save(&path)?;
|
||||
println!("created {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
ConfigCommand::Site(command) => handle_site(command, path),
|
||||
ConfigCommand::Mirror(command) => handle_mirror(command, path),
|
||||
ConfigCommand::Show => {
|
||||
let config = Config::load(&path)?;
|
||||
println!("{}", toml::to_string_pretty(&config)?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_site(command: SiteCommand, path: PathBuf) -> Result<()> {
|
||||
let mut config = Config::load_or_default(&path)?;
|
||||
match command {
|
||||
SiteCommand::Add(args) => {
|
||||
let token = match (args.token, args.token_env) {
|
||||
(Some(value), None) => TokenConfig::Value(value),
|
||||
(None, Some(env)) => TokenConfig::Env(env),
|
||||
(None, None) => bail!("pass either --token or --token-env"),
|
||||
(Some(_), Some(_)) => unreachable!("clap enforces token conflicts"),
|
||||
};
|
||||
config.upsert_site(SiteConfig {
|
||||
name: args.name,
|
||||
provider: args.provider.into(),
|
||||
base_url: args.base_url,
|
||||
api_url: args.api_url,
|
||||
token,
|
||||
git_username: args.git_username,
|
||||
});
|
||||
config.save(&path)?;
|
||||
println!("updated {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
SiteCommand::Remove(args) => {
|
||||
config.remove_site(&args.name)?;
|
||||
config.save(&path)?;
|
||||
println!("removed site {}", args.name);
|
||||
Ok(())
|
||||
}
|
||||
SiteCommand::List => {
|
||||
for site in &config.sites {
|
||||
println!("{}\t{:?}\t{}", site.name, site.provider, site.base_url);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mirror(command: MirrorCommand, path: PathBuf) -> Result<()> {
|
||||
let mut config = Config::load_or_default(&path)?;
|
||||
match command {
|
||||
MirrorCommand::Add(args) => {
|
||||
if args.endpoints.len() < 2 {
|
||||
bail!("mirror groups need at least two --endpoint values");
|
||||
}
|
||||
let endpoints = args
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|value| parse_endpoint(value))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
for endpoint in &endpoints {
|
||||
config
|
||||
.site(&endpoint.site)
|
||||
.with_context(|| format!("unknown site '{}'", endpoint.site))?;
|
||||
}
|
||||
config.upsert_mirror(config::MirrorConfig {
|
||||
name: args.name,
|
||||
endpoints,
|
||||
create_missing: args.create_missing,
|
||||
visibility: args.visibility.into(),
|
||||
allow_force: args.allow_force,
|
||||
});
|
||||
config.save(&path)?;
|
||||
println!("updated {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
MirrorCommand::Remove(args) => {
|
||||
config.remove_mirror(&args.name)?;
|
||||
config.save(&path)?;
|
||||
println!("removed mirror {}", args.name);
|
||||
Ok(())
|
||||
}
|
||||
MirrorCommand::List => {
|
||||
for mirror in &config.mirrors {
|
||||
let endpoints = mirror
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|endpoint| {
|
||||
format!(
|
||||
"{}:{:?}:{}",
|
||||
endpoint.site, endpoint.kind, endpoint.namespace
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
println!("{}\t{}", mirror.name, endpoints);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_endpoint(value: &str) -> Result<EndpointConfig> {
|
||||
let parts = value.splitn(3, ':').collect::<Vec<_>>();
|
||||
if parts.len() != 3 {
|
||||
bail!("endpoint must be SITE:KIND:NAMESPACE, got '{value}'");
|
||||
}
|
||||
|
||||
let kind = match parts[1].to_ascii_lowercase().as_str() {
|
||||
"user" => NamespaceKind::User,
|
||||
"org" | "organization" => NamespaceKind::Org,
|
||||
"group" => NamespaceKind::Group,
|
||||
other => bail!("unsupported namespace kind '{other}'"),
|
||||
};
|
||||
|
||||
Ok(EndpointConfig {
|
||||
site: parts[0].to_string(),
|
||||
kind,
|
||||
namespace: parts[2].to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
impl From<ProviderArg> for ProviderKind {
|
||||
fn from(value: ProviderArg) -> Self {
|
||||
match value {
|
||||
ProviderArg::Github => Self::Github,
|
||||
ProviderArg::Gitlab => Self::Gitlab,
|
||||
ProviderArg::Gitea => Self::Gitea,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<VisibilityArg> for Visibility {
|
||||
fn from(value: VisibilityArg) -> Self {
|
||||
match value {
|
||||
VisibilityArg::Private => Self::Private,
|
||||
VisibilityArg::Public => Self::Public,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cli_accepts_repeated_mirror_endpoints() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"git-sync",
|
||||
"config",
|
||||
"mirror",
|
||||
"add",
|
||||
"--name",
|
||||
"personal",
|
||||
"--endpoint",
|
||||
"github:user:hykilpikonna",
|
||||
"--endpoint",
|
||||
"gitea:user:azalea",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let Command::Config(ConfigCommand::Mirror(MirrorCommand::Add(args))) = cli.command else {
|
||||
panic!("parsed unexpected command");
|
||||
};
|
||||
assert_eq!(args.name, "personal");
|
||||
assert_eq!(
|
||||
args.endpoints,
|
||||
vec![
|
||||
"github:user:hykilpikonna".to_string(),
|
||||
"gitea:user:azalea".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_parser_supports_aliases_and_rejects_bad_kinds() {
|
||||
let endpoint = parse_endpoint("github:organization:MewoLab").unwrap();
|
||||
assert_eq!(endpoint.site, "github");
|
||||
assert_eq!(endpoint.kind, NamespaceKind::Org);
|
||||
assert_eq!(endpoint.namespace, "MewoLab");
|
||||
|
||||
let endpoint = parse_endpoint("gitlab:group:parent/child").unwrap();
|
||||
assert_eq!(endpoint.kind, NamespaceKind::Group);
|
||||
|
||||
let err = parse_endpoint("github:team:alice").unwrap_err().to_string();
|
||||
assert!(err.contains("unsupported namespace kind 'team'"));
|
||||
|
||||
let err = parse_endpoint("github:user").unwrap_err().to_string();
|
||||
assert!(err.contains("SITE:KIND:NAMESPACE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_add_requires_one_token_source() {
|
||||
let missing = Cli::try_parse_from([
|
||||
"git-sync",
|
||||
"config",
|
||||
"site",
|
||||
"add",
|
||||
"--name",
|
||||
"github",
|
||||
"--provider",
|
||||
"github",
|
||||
"--base-url",
|
||||
"https://github.com",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let Command::Config(ConfigCommand::Site(SiteCommand::Add(args))) = missing.command else {
|
||||
panic!("parsed unexpected command");
|
||||
};
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let err = handle_site(SiteCommand::Add(args), temp.path().join("config.toml"))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("pass either --token or --token-env"));
|
||||
|
||||
let conflict = Cli::try_parse_from([
|
||||
"git-sync",
|
||||
"config",
|
||||
"site",
|
||||
"add",
|
||||
"--name",
|
||||
"github",
|
||||
"--provider",
|
||||
"github",
|
||||
"--base-url",
|
||||
"https://github.com",
|
||||
"--token",
|
||||
"a",
|
||||
"--token-env",
|
||||
"GITHUB_TOKEN",
|
||||
]);
|
||||
assert!(conflict.is_err());
|
||||
}
|
||||
}
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use url::Url;
|
||||
|
||||
use crate::config::{EndpointConfig, NamespaceKind, ProviderKind, SiteConfig, Visibility};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteRepo {
|
||||
pub name: String,
|
||||
pub clone_url: String,
|
||||
pub private: bool,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EndpointRepo {
|
||||
pub endpoint: EndpointConfig,
|
||||
pub repo: RemoteRepo,
|
||||
}
|
||||
|
||||
pub struct ProviderClient<'a> {
|
||||
site: &'a SiteConfig,
|
||||
token: String,
|
||||
http: Client,
|
||||
}
|
||||
|
||||
impl<'a> ProviderClient<'a> {
|
||||
pub fn new(site: &'a SiteConfig) -> Result<Self> {
|
||||
let token = site.token()?;
|
||||
Ok(Self {
|
||||
site,
|
||||
token,
|
||||
http: Client::builder().build()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_repos(&self, endpoint: &EndpointConfig) -> Result<Vec<RemoteRepo>> {
|
||||
match self.site.provider {
|
||||
ProviderKind::Github => self.github_list_repos(endpoint),
|
||||
ProviderKind::Gitlab => self.gitlab_list_repos(endpoint),
|
||||
ProviderKind::Gitea => self.gitea_list_repos(endpoint),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_repo(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
name: &str,
|
||||
visibility: &Visibility,
|
||||
description: Option<&str>,
|
||||
) -> Result<RemoteRepo> {
|
||||
match self.site.provider {
|
||||
ProviderKind::Github => {
|
||||
self.github_create_repo(endpoint, name, visibility, description)
|
||||
}
|
||||
ProviderKind::Gitlab => {
|
||||
self.gitlab_create_repo(endpoint, name, visibility, description)
|
||||
}
|
||||
ProviderKind::Gitea => self.gitea_create_repo(endpoint, name, visibility, description),
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
.with_context(|| format!("failed to parse clone URL '{clone_url}'"))?;
|
||||
if url.scheme() != "https" && url.scheme() != "http" {
|
||||
bail!("only HTTP(S) clone URLs are supported, got '{clone_url}'");
|
||||
}
|
||||
|
||||
let username = self
|
||||
.site
|
||||
.git_username
|
||||
.clone()
|
||||
.unwrap_or_else(|| match self.site.provider {
|
||||
ProviderKind::Github => "x-access-token".to_string(),
|
||||
ProviderKind::Gitlab | ProviderKind::Gitea => "oauth2".to_string(),
|
||||
});
|
||||
url.set_username(&username)
|
||||
.map_err(|_| anyhow!("failed to set username on clone URL"))?;
|
||||
url.set_password(Some(&self.token))
|
||||
.map_err(|_| anyhow!("failed to set token on clone URL"))?;
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn github_list_repos(&self, endpoint: &EndpointConfig) -> Result<Vec<RemoteRepo>> {
|
||||
match endpoint.kind {
|
||||
NamespaceKind::User => {
|
||||
let url = format!(
|
||||
"{}/user/repos?affiliation=owner&visibility=all&per_page=100",
|
||||
self.site.api_base()
|
||||
);
|
||||
let repos: Vec<GithubRepo> = self
|
||||
.paged_get(&url)?
|
||||
.into_iter()
|
||||
.filter(|repo: &GithubRepo| {
|
||||
repo.owner.login.eq_ignore_ascii_case(&endpoint.namespace)
|
||||
})
|
||||
.collect();
|
||||
Ok(repos.into_iter().map(Into::into).collect())
|
||||
}
|
||||
NamespaceKind::Org => {
|
||||
let url = format!(
|
||||
"{}/orgs/{}/repos?type=all&per_page=100",
|
||||
self.site.api_base(),
|
||||
endpoint.namespace
|
||||
);
|
||||
let repos: Vec<GithubRepo> = self.paged_get(&url)?;
|
||||
Ok(repos.into_iter().map(Into::into).collect())
|
||||
}
|
||||
NamespaceKind::Group => bail!("GitHub endpoints use kind 'user' or 'org'"),
|
||||
}
|
||||
}
|
||||
|
||||
fn github_create_repo(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
name: &str,
|
||||
visibility: &Visibility,
|
||||
description: Option<&str>,
|
||||
) -> Result<RemoteRepo> {
|
||||
let url = match endpoint.kind {
|
||||
NamespaceKind::User => format!("{}/user/repos", self.site.api_base()),
|
||||
NamespaceKind::Org => {
|
||||
format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace)
|
||||
}
|
||||
NamespaceKind::Group => bail!("GitHub endpoints use kind 'user' or 'org'"),
|
||||
};
|
||||
let body = json!({
|
||||
"name": name,
|
||||
"private": matches!(visibility, Visibility::Private),
|
||||
"description": description.unwrap_or(""),
|
||||
});
|
||||
self.post_json::<GithubRepo>(&url, &body).map(Into::into)
|
||||
}
|
||||
|
||||
fn gitlab_list_repos(&self, endpoint: &EndpointConfig) -> Result<Vec<RemoteRepo>> {
|
||||
match endpoint.kind {
|
||||
NamespaceKind::User => {
|
||||
let url = format!(
|
||||
"{}/users/{}/projects?simple=true&per_page=100&owned=true",
|
||||
self.site.api_base(),
|
||||
endpoint.namespace
|
||||
);
|
||||
let repos: Vec<GitlabProject> = self.paged_get(&url)?;
|
||||
Ok(repos.into_iter().map(Into::into).collect())
|
||||
}
|
||||
NamespaceKind::Org | NamespaceKind::Group => {
|
||||
let encoded = urlencoding(&endpoint.namespace);
|
||||
let url = format!(
|
||||
"{}/groups/{}/projects?simple=true&include_subgroups=false&per_page=100",
|
||||
self.site.api_base(),
|
||||
encoded
|
||||
);
|
||||
let repos: Vec<GitlabProject> = self.paged_get(&url)?;
|
||||
Ok(repos.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gitlab_create_repo(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
name: &str,
|
||||
visibility: &Visibility,
|
||||
description: Option<&str>,
|
||||
) -> Result<RemoteRepo> {
|
||||
let mut body = serde_json::Map::from_iter([
|
||||
("name".to_string(), json!(name)),
|
||||
("path".to_string(), json!(name)),
|
||||
(
|
||||
"visibility".to_string(),
|
||||
json!(match visibility {
|
||||
Visibility::Private => "private",
|
||||
Visibility::Public => "public",
|
||||
}),
|
||||
),
|
||||
("description".to_string(), json!(description.unwrap_or(""))),
|
||||
]);
|
||||
|
||||
if matches!(endpoint.kind, NamespaceKind::Org | NamespaceKind::Group) {
|
||||
let group = self.gitlab_group(&endpoint.namespace)?;
|
||||
body.insert("namespace_id".to_string(), json!(group.id));
|
||||
}
|
||||
|
||||
let url = format!("{}/projects", self.site.api_base());
|
||||
self.post_json::<GitlabProject>(&url, &serde_json::Value::Object(body))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn gitlab_group(&self, namespace: &str) -> Result<GitlabGroup> {
|
||||
let url = format!("{}/groups/{}", self.site.api_base(), urlencoding(namespace));
|
||||
self.get_json(&url)
|
||||
}
|
||||
|
||||
fn gitea_list_repos(&self, endpoint: &EndpointConfig) -> Result<Vec<RemoteRepo>> {
|
||||
match endpoint.kind {
|
||||
NamespaceKind::User => {
|
||||
let url = format!("{}/user/repos?limit=50", self.site.api_base());
|
||||
let repos: Vec<GiteaRepo> = self
|
||||
.paged_get(&url)?
|
||||
.into_iter()
|
||||
.filter(|repo: &GiteaRepo| {
|
||||
repo.owner.login.eq_ignore_ascii_case(&endpoint.namespace)
|
||||
})
|
||||
.collect();
|
||||
Ok(repos.into_iter().map(Into::into).collect())
|
||||
}
|
||||
NamespaceKind::Org => {
|
||||
let url = format!(
|
||||
"{}/orgs/{}/repos?limit=50",
|
||||
self.site.api_base(),
|
||||
endpoint.namespace
|
||||
);
|
||||
let repos: Vec<GiteaRepo> = self.paged_get(&url)?;
|
||||
Ok(repos.into_iter().map(Into::into).collect())
|
||||
}
|
||||
NamespaceKind::Group => bail!("Gitea endpoints use kind 'user' or 'org'"),
|
||||
}
|
||||
}
|
||||
|
||||
fn gitea_create_repo(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
name: &str,
|
||||
visibility: &Visibility,
|
||||
description: Option<&str>,
|
||||
) -> Result<RemoteRepo> {
|
||||
let url = match endpoint.kind {
|
||||
NamespaceKind::User => format!("{}/user/repos", self.site.api_base()),
|
||||
NamespaceKind::Org => {
|
||||
format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace)
|
||||
}
|
||||
NamespaceKind::Group => bail!("Gitea endpoints use kind 'user' or 'org'"),
|
||||
};
|
||||
let body = json!({
|
||||
"name": name,
|
||||
"private": matches!(visibility, Visibility::Private),
|
||||
"description": description.unwrap_or(""),
|
||||
"auto_init": false,
|
||||
});
|
||||
self.post_json::<GiteaRepo>(&url, &body).map(Into::into)
|
||||
}
|
||||
|
||||
fn paged_get<T>(&self, first_url: &str) -> Result<Vec<T>>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let mut output = Vec::new();
|
||||
let mut next_url = Some(first_url.to_string());
|
||||
|
||||
while let Some(url) = next_url.take() {
|
||||
let response = self.get(&url)?;
|
||||
next_url = next_link(response.headers());
|
||||
let mut page: Vec<T> = response
|
||||
.json()
|
||||
.with_context(|| format!("invalid JSON from {url}"))?;
|
||||
output.append(&mut page);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn get_json<T>(&self, url: &str) -> Result<T>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
self.get(url)?
|
||||
.json()
|
||||
.with_context(|| format!("invalid JSON from {url}"))
|
||||
}
|
||||
|
||||
fn post_json<T>(&self, url: &str, body: &serde_json::Value) -> Result<T>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
self.request_headers(self.http.post(url))?
|
||||
.json(body)
|
||||
.send()
|
||||
.with_context(|| format!("POST {url} failed"))
|
||||
.and_then(|response| check_response("POST", url, response))?
|
||||
.json()
|
||||
.with_context(|| format!("invalid JSON from {url}"))
|
||||
}
|
||||
|
||||
fn get(&self, url: &str) -> Result<Response> {
|
||||
self.request_headers(self.http.get(url))?
|
||||
.send()
|
||||
.with_context(|| format!("GET {url} failed"))
|
||||
.and_then(|response| check_response("GET", url, response))
|
||||
}
|
||||
|
||||
fn request_headers(
|
||||
&self,
|
||||
request: reqwest::blocking::RequestBuilder,
|
||||
) -> Result<reqwest::blocking::RequestBuilder> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static("git-sync/0.1"));
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
match self.site.provider {
|
||||
ProviderKind::Github => {
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {}", self.token))
|
||||
.context("PAT contains invalid header characters")?,
|
||||
);
|
||||
headers.insert(
|
||||
"X-GitHub-Api-Version",
|
||||
HeaderValue::from_static("2022-11-28"),
|
||||
);
|
||||
}
|
||||
ProviderKind::Gitlab => {
|
||||
headers.insert(
|
||||
"PRIVATE-TOKEN",
|
||||
HeaderValue::from_str(&self.token)
|
||||
.context("PAT contains invalid header characters")?,
|
||||
);
|
||||
}
|
||||
ProviderKind::Gitea => {
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("token {}", self.token))
|
||||
.context("PAT contains invalid header characters")?,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(request.headers(headers))
|
||||
}
|
||||
}
|
||||
|
||||
fn check_response(method: &str, url: &str, response: Response) -> Result<Response> {
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
let status = response.status();
|
||||
let body = response.text().unwrap_or_default();
|
||||
bail!("{method} {url} returned {status}: {body}");
|
||||
}
|
||||
|
||||
fn next_link(headers: &HeaderMap) -> Option<String> {
|
||||
let header = headers.get("link")?.to_str().ok()?;
|
||||
for part in header.split(',') {
|
||||
let mut sections = part.trim().split(';');
|
||||
let url = sections.next()?.trim();
|
||||
let rel = sections.any(|section| section.trim() == "rel=\"next\"");
|
||||
if rel {
|
||||
return url
|
||||
.strip_prefix('<')
|
||||
.and_then(|value| value.strip_suffix('>'))
|
||||
.map(ToString::to_string);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn urlencoding(value: &str) -> String {
|
||||
url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GithubRepo {
|
||||
name: String,
|
||||
clone_url: String,
|
||||
private: bool,
|
||||
description: Option<String>,
|
||||
owner: GithubOwner,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GithubOwner {
|
||||
login: String,
|
||||
}
|
||||
|
||||
impl From<GithubRepo> for RemoteRepo {
|
||||
fn from(value: GithubRepo) -> Self {
|
||||
Self {
|
||||
name: value.name,
|
||||
clone_url: value.clone_url,
|
||||
private: value.private,
|
||||
description: value.description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitlabProject {
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
http_url_to_repo: String,
|
||||
visibility: String,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
impl From<GitlabProject> for RemoteRepo {
|
||||
fn from(value: GitlabProject) -> Self {
|
||||
Self {
|
||||
name: value.path.unwrap_or(value.name),
|
||||
clone_url: value.http_url_to_repo,
|
||||
private: value.visibility != "public",
|
||||
description: value.description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitlabGroup {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GiteaRepo {
|
||||
name: String,
|
||||
clone_url: String,
|
||||
private: bool,
|
||||
description: Option<String>,
|
||||
owner: GiteaOwner,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GiteaOwner {
|
||||
login: String,
|
||||
}
|
||||
|
||||
impl From<GiteaRepo> for RemoteRepo {
|
||||
fn from(value: GiteaRepo) -> Self {
|
||||
Self {
|
||||
name: value.name,
|
||||
clone_url: value.clone_url,
|
||||
private: value.private,
|
||||
description: value.description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn repos_by_name(repos: Vec<EndpointRepo>) -> HashMap<String, Vec<EndpointRepo>> {
|
||||
let mut output: HashMap<String, Vec<EndpointRepo>> = HashMap::new();
|
||||
for repo in repos {
|
||||
output.entry(repo.repo.name.clone()).or_default().push(repo);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::TokenConfig;
|
||||
|
||||
#[test]
|
||||
fn extracts_next_link() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"link",
|
||||
HeaderValue::from_static("<https://example.test?page=2>; rel=\"next\", <https://example.test?page=5>; rel=\"last\""),
|
||||
);
|
||||
assert_eq!(next_link(&headers).unwrap(), "https://example.test?page=2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_clone_urls_use_provider_defaults() {
|
||||
let github_site = site(ProviderKind::Github, None);
|
||||
let github = ProviderClient::new(&github_site).unwrap();
|
||||
assert_eq!(
|
||||
github
|
||||
.authenticated_clone_url("https://github.com/alice/repo.git")
|
||||
.unwrap(),
|
||||
"https://x-access-token:secret@github.com/alice/repo.git"
|
||||
);
|
||||
|
||||
let gitlab_site = site(ProviderKind::Gitlab, None);
|
||||
let gitlab = ProviderClient::new(&gitlab_site).unwrap();
|
||||
assert_eq!(
|
||||
gitlab
|
||||
.authenticated_clone_url("https://gitlab.example.test/alice/repo.git")
|
||||
.unwrap(),
|
||||
"https://oauth2:secret@gitlab.example.test/alice/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_clone_urls_can_override_git_username() {
|
||||
let gitea_site = site(ProviderKind::Gitea, Some("mirror-user".to_string()));
|
||||
let client = ProviderClient::new(&gitea_site).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
client
|
||||
.authenticated_clone_url("https://gitea.example.test/alice/repo.git")
|
||||
.unwrap(),
|
||||
"https://mirror-user:secret@gitea.example.test/alice/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_paths_are_url_encoded_for_gitlab() {
|
||||
assert_eq!(urlencoding("parent/child group"), "parent%2Fchild+group");
|
||||
}
|
||||
|
||||
fn site(provider: ProviderKind, git_username: Option<String>) -> SiteConfig {
|
||||
SiteConfig {
|
||||
name: "site".to_string(),
|
||||
provider,
|
||||
base_url: "https://example.test".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("secret".to_string()),
|
||||
git_username,
|
||||
}
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use crate::config::{Config, EndpointConfig, MirrorConfig, default_work_dir, validate_config};
|
||||
use crate::git::{GitMirror, Redactor, RemoteSpec, safe_remote_name};
|
||||
use crate::provider::{EndpointRepo, ProviderClient, repos_by_name};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SyncOptions {
|
||||
pub group: Option<String>,
|
||||
pub dry_run: bool,
|
||||
pub create_missing_override: Option<bool>,
|
||||
pub force_override: Option<bool>,
|
||||
pub work_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub fn sync_all(config: &Config, options: SyncOptions) -> Result<()> {
|
||||
validate_config(config)?;
|
||||
let work_dir = options.work_dir.clone().unwrap_or_else(default_work_dir);
|
||||
fs::create_dir_all(&work_dir)
|
||||
.with_context(|| format!("failed to create {}", work_dir.display()))?;
|
||||
|
||||
let mirrors = config
|
||||
.mirrors
|
||||
.iter()
|
||||
.filter(|mirror| {
|
||||
options
|
||||
.group
|
||||
.as_ref()
|
||||
.is_none_or(|name| mirror.name == *name)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if mirrors.is_empty() {
|
||||
bail!("no mirror group matched");
|
||||
}
|
||||
|
||||
let tokens = config
|
||||
.sites
|
||||
.iter()
|
||||
.map(|site| site.token())
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let redactor = Redactor::new(tokens);
|
||||
|
||||
for mirror in mirrors {
|
||||
sync_group(config, mirror, &options, &work_dir, redactor.clone())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_group(
|
||||
config: &Config,
|
||||
mirror: &MirrorConfig,
|
||||
options: &SyncOptions,
|
||||
work_dir: &Path,
|
||||
redactor: Redactor,
|
||||
) -> Result<()> {
|
||||
println!("syncing mirror group {}", mirror.name);
|
||||
let create_missing = options
|
||||
.create_missing_override
|
||||
.unwrap_or(mirror.create_missing);
|
||||
let allow_force = options.force_override.unwrap_or(mirror.allow_force);
|
||||
|
||||
let mut all_endpoint_repos = Vec::new();
|
||||
for endpoint in &mirror.endpoints {
|
||||
let site = config.site(&endpoint.site).unwrap();
|
||||
let client = ProviderClient::new(site)?;
|
||||
println!("listing {}", endpoint.label());
|
||||
let repos = client
|
||||
.list_repos(endpoint)
|
||||
.with_context(|| format!("failed to list repos for {}", endpoint.label()))?;
|
||||
for repo in repos {
|
||||
all_endpoint_repos.push(EndpointRepo {
|
||||
endpoint: endpoint.clone(),
|
||||
repo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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(());
|
||||
}
|
||||
|
||||
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,
|
||||
work_dir,
|
||||
redactor: redactor.clone(),
|
||||
dry_run: options.dry_run,
|
||||
allow_force,
|
||||
};
|
||||
sync_repo(&context, &repo_name, &existing)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_missing_repos(
|
||||
config: &Config,
|
||||
mirror: &MirrorConfig,
|
||||
repo_name: &str,
|
||||
existing: &mut Vec<EndpointRepo>,
|
||||
create_missing: bool,
|
||||
dry_run: bool,
|
||||
) -> Result<()> {
|
||||
let present = existing
|
||||
.iter()
|
||||
.map(|repo| repo.endpoint.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let template = existing.first().map(|repo| repo.repo.clone());
|
||||
|
||||
for endpoint in &mirror.endpoints {
|
||||
if present.contains(endpoint) {
|
||||
continue;
|
||||
}
|
||||
if !create_missing {
|
||||
println!(
|
||||
"{} is missing on {}; creation disabled",
|
||||
repo_name,
|
||||
endpoint.label()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
println!("creating {} on {}", repo_name, endpoint.label());
|
||||
if dry_run {
|
||||
continue;
|
||||
}
|
||||
|
||||
let site = config.site(&endpoint.site).unwrap();
|
||||
let client = ProviderClient::new(site)?;
|
||||
let created = client
|
||||
.create_repo(
|
||||
endpoint,
|
||||
repo_name,
|
||||
&mirror.visibility,
|
||||
template
|
||||
.as_ref()
|
||||
.and_then(|repo| repo.description.as_deref()),
|
||||
)
|
||||
.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()
|
||||
);
|
||||
}
|
||||
existing.push(EndpointRepo {
|
||||
endpoint: endpoint.clone(),
|
||||
repo: created,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct RepoSyncContext<'a> {
|
||||
config: &'a Config,
|
||||
mirror: &'a MirrorConfig,
|
||||
work_dir: &'a Path,
|
||||
redactor: Redactor,
|
||||
dry_run: bool,
|
||||
allow_force: bool,
|
||||
}
|
||||
|
||||
fn sync_repo(context: &RepoSyncContext<'_>, repo_name: &str, repos: &[EndpointRepo]) -> Result<()> {
|
||||
println!("syncing repo {}", repo_name);
|
||||
let endpoint_map = context
|
||||
.mirror
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|endpoint| (endpoint.clone(), endpoint))
|
||||
.collect::<HashMap<EndpointConfig, &EndpointConfig>>();
|
||||
let mut remotes = Vec::new();
|
||||
|
||||
for endpoint_repo in repos {
|
||||
if !endpoint_map.contains_key(&endpoint_repo.endpoint) {
|
||||
continue;
|
||||
}
|
||||
let site = context.config.site(&endpoint_repo.endpoint.site).unwrap();
|
||||
let client = ProviderClient::new(site)?;
|
||||
let remote_name = safe_remote_name(&format!(
|
||||
"{}_{}",
|
||||
endpoint_repo.endpoint.site, endpoint_repo.endpoint.namespace
|
||||
));
|
||||
remotes.push(RemoteSpec {
|
||||
name: remote_name,
|
||||
url: client.authenticated_clone_url(&endpoint_repo.repo.clone_url)?,
|
||||
display: endpoint_repo.endpoint.label(),
|
||||
});
|
||||
}
|
||||
|
||||
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)?;
|
||||
}
|
||||
|
||||
let (branches, conflicts) = mirror_repo.branch_decisions(&remotes, context.allow_force)?;
|
||||
for conflict in conflicts {
|
||||
let details = conflict
|
||||
.tips
|
||||
.iter()
|
||||
.map(|(remote, sha)| format!("{remote}@{}", short_sha(sha)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
println!(
|
||||
"conflict in {}/{}: branch {} diverged across {}. Skipping that branch.",
|
||||
context.mirror.name, repo_name, conflict.branch, details
|
||||
);
|
||||
}
|
||||
|
||||
let (tags, tag_conflicts) = mirror_repo.tag_decisions(&remotes)?;
|
||||
for conflict in tag_conflicts {
|
||||
let details = conflict
|
||||
.tips
|
||||
.iter()
|
||||
.map(|(remote, sha)| format!("{remote}@{}", short_sha(sha)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
println!(
|
||||
"conflict in {}/{}: tag {} differs across {}. Skipping that tag.",
|
||||
context.mirror.name, repo_name, conflict.tag, details
|
||||
);
|
||||
}
|
||||
|
||||
if branches.is_empty() && tags.is_empty() {
|
||||
println!("{} has no branches or tags to push", repo_name);
|
||||
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 !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)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn short_sha(sha: &str) -> &str {
|
||||
sha.get(..12).unwrap_or(sha)
|
||||
}
|
||||
Reference in New Issue
Block a user