diff --git a/sync4src/Cargo.toml b/sync4src/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..dfbecd9c13cee8db4ae14e33a819b695b1dc597d --- /dev/null +++ b/sync4src/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "sync4src" +version = "0.1.0" +edition = "2021" +authors = ["Hengke Sun "] +description = "A utility to sync between two repositories" +readme= "README.md" +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap = { version = "3.2.16", features = ["derive"] } +config = { version = "0.13.2", features = ["yaml"], default-features = false } +git2 = "0.15.0" + +[profile.release] +codegen-units = 1 +lto = true +opt-level = 3 + +[profile.dev] +split-debuginfo = "unpacked" diff --git a/sync4src/README.md b/sync4src/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c5469687838babe6361385f7be6fabefd3cafc05 --- /dev/null +++ b/sync4src/README.md @@ -0,0 +1,68 @@ +# sync4src +## 简介 + +Rust编写的用于在两个仓库间建立单向同步的小工具。 +> Note: 同步时会同步所有分支 (branch) 和标签 (tag) + +## 使用 +### 查看使用帮助 +```shell +cargo run -- --help +``` +```text +sync4src 0.1.0 +Hengke Sun +A utility to sync between two repositories + +USAGE: + sync4src --config + +OPTIONS: + -c, --config Config file path + -h, --help Print help information + -V, --version Print version information +``` + + +### 进行同步 +```shell +cargo run -- -c ./config.yaml +``` +```text +sync branch origin/feat/test to target repo +sync branch origin/main to target repo +sync tag 0.0.1 to target repo +sync tag 1.0.0 to target repo +``` + +其中`-c`或`--config`为必需参数,指定配置文件路径。 + +配置文件支持YAML格式,内容形如: +```yaml +source: + url: git@github.com:greenhandatsjtu/sync4src.git + username: git + ssh_key: /home/sunhengke/.ssh/id_rsa + +target: + url: https://gitee.com/sunhengke1/sync4src.git + username: sunhengke1 + password: test123 +``` +其中,source为源同步仓库的配置,target为目标同步仓库的配置。具体来说: ++ `url`为仓库链接,支持HTTP(S)和SSH ++ `username`为用户名,为可选项,若不提供用户名,`sync4src`会尝试从`url`中读取用户名 ++ `ssh_key`和`password`代表两种认证方式,SSH密钥认证或用户名密码认证;若使用SSH密钥认证,用户需要指定SSH密钥的文件路径,若使用用户名密码认证,用户需要提供密码;若两者都不提供,`sync4src`会尝试使用`ssh-agent`进行认证,或者读取`~/.ssh/id_rsa`作为SSH密钥 + + +### 定时同步 +使用`crontab`可以实现每小时或每日进行同步。 +```shell +crontab -e +``` +```text +# 每日 +0 0 * * * .../sync4src -c .../config.yaml +# 每小时 +0 * * * * .../sync4src -c .../config.yaml +``` \ No newline at end of file diff --git a/sync4src/config.yaml b/sync4src/config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9c6c48b00d2c58fc143729c873b6d22ff4fd530e --- /dev/null +++ b/sync4src/config.yaml @@ -0,0 +1,9 @@ +source: + url: git@github.com:greenhandatsjtu/sync4src.git + username: git + ssh_key: /home/sunhengke/.ssh/id_rsa + +target: + url: https://gitee.com/sunhengke1/sync4src.git + username: sunhengke1 + password: test123 diff --git a/sync4src/src/conf.rs b/sync4src/src/conf.rs new file mode 100644 index 0000000000000000000000000000000000000000..8c9dd1c1c62b6fd1fff94506ad9064e591c4ef1a --- /dev/null +++ b/sync4src/src/conf.rs @@ -0,0 +1,82 @@ +use clap::Parser; +use config::Config; +use std::collections::HashMap; +use std::error::Error; + +/// Commandline arguments +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +struct Args { + /// Config file path + #[clap(short, long, value_parser, value_name = "FILE")] + config: String, +} + +#[derive(Debug, Clone)] +pub enum Credential { + SshKey(String), + Password(String), +} + +#[derive(Debug, Clone)] +pub struct Remote { + pub url: String, + pub username: Option, + pub cred: Option, +} + +#[derive(Debug, Clone)] +pub struct Conf { + pub source: Remote, // source repository + pub target: Remote, // target repository +} + +/// parse the configuration and return `Conf` +pub fn parse_conf() -> Result> { + let args: Args = Args::parse(); + let settings = Config::builder() + .add_source(config::File::with_name(args.config.as_str())) + .build() + .unwrap(); + + let source: HashMap = settings.get("source")?; + let source_url = source.get("url").ok_or("url field not found")?.to_string(); + let source_username = source.get("username").map(|x| x.to_string()); + let source_cred = if source.contains_key("ssh_key") { + Some(Credential::SshKey( + source.get("ssh_key").unwrap().to_string(), + )) + } else if source.contains_key("password") { + let password = source.get("password").unwrap().to_string(); + Some(Credential::Password(password)) + } else { + None + }; + + let target: HashMap = settings.get("target")?; + let target_url = target.get("url").ok_or("url field not found")?.to_string(); + let target_username = target.get("username").map(|x| x.to_string()); + let target_cred = if target.contains_key("ssh_key") { + Some(Credential::SshKey( + target.get("ssh_key").unwrap().to_string(), + )) + } else if target.contains_key("password") { + let password = target.get("password").unwrap().to_string(); + Some(Credential::Password(password)) + } else { + None + }; + + Ok(Conf { + source: Remote { + url: source_url, + username: source_username, + cred: source_cred, + }, + target: Remote { + url: target_url, + username: target_username, + cred: target_cred, + }, + }) +} diff --git a/sync4src/src/main.rs b/sync4src/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..ce1cc193cf886bbe988325efeb5b8e3b27afc074 --- /dev/null +++ b/sync4src/src/main.rs @@ -0,0 +1,10 @@ +mod conf; +mod sync; + +fn main() { + let conf = conf::parse_conf().expect("failed to parse config"); + + let repo = sync::clone(&conf.source).expect("failed to clone repository"); + + sync::push_to_target(&repo, &conf.target).expect("failed to push to target repository"); +} diff --git a/sync4src/src/sync.rs b/sync4src/src/sync.rs new file mode 100644 index 0000000000000000000000000000000000000000..006268d0a2cdc8b000e624224dd5a0e066a429af --- /dev/null +++ b/sync4src/src/sync.rs @@ -0,0 +1,112 @@ +use crate::conf::{Credential, Remote}; +use git2::{BranchType, Cred, CredentialType, Error, FetchOptions, PushOptions, RemoteCallbacks, Repository}; +use std::fs::{remove_dir_all, remove_file}; +use std::{env, path::Path}; + +/// Return `RemoteCallbacks` based on authentication type +fn remote_callbacks(remote: &Remote) -> Result { + let mut callbacks = RemoteCallbacks::new(); + callbacks.credentials(|_url, username_from_url, allowed_types| { + let username = if let Some(name) = &remote.username { + name + } else { + username_from_url.unwrap() + }; + if let Some(cred) = &remote.cred { + return match cred { + Credential::SshKey(key) => Cred::ssh_key(username, None, Path::new(key), None), + Credential::Password(passwd) => Cred::userpass_plaintext(username, passwd), + }; + } else if allowed_types.contains(CredentialType::SSH_KEY) { + // ref: https://github.com/martinvonz/jj/blob/main/lib/src/git.rs + if env::var("SSH_AUTH_SOCK").is_ok() || env::var("SSH_AGENT_PID").is_ok() { + return Cred::ssh_key_from_agent(username); + } else if let Ok(home_dir) = env::var("HOME") { + let key_path = Path::new(&home_dir).join(".ssh").join("id_rsa"); + if key_path.is_file() { + return Cred::ssh_key(username, None, &key_path, None); + } + } + } + Cred::default() + }); + Ok(callbacks) +} + +/// Return `FetchOptions` +fn fetch_options(remote: &Remote) -> Result { + let callbacks = remote_callbacks(remote)?; + let mut fetch_option = FetchOptions::new(); + fetch_option.remote_callbacks(callbacks); + Ok(fetch_option) +} + +/// Return `PushOptions` +fn push_options(remote: &Remote) -> Result { + let callbacks = remote_callbacks(remote)?; + let mut push_option = PushOptions::new(); + push_option.remote_callbacks(callbacks); + Ok(push_option) +} + +/// clone repository +pub fn clone(source: &Remote) -> Result { + let fo = fetch_options(source)?; + + // prepare builder + let mut builder = git2::build::RepoBuilder::new(); + builder.fetch_options(fo); + + let path = Path::new("/tmp").join("git-sync").join("source"); + + if path.exists() { + if path.is_dir() { + remove_dir_all(path.as_path()) + .map_err(|err| Error::from_str(err.to_string().as_str()))?; + } else { + remove_file(path.as_path()).map_err(|err| Error::from_str(err.to_string().as_str()))?; + } + } + + // clone the project + let repo = builder.clone(&source.url, path.as_path())?; + + Ok(repo) +} + +/// push commits of source to target +pub fn push_to_target(repo: &Repository, target: &Remote) -> Result<(), Error> { + // add target remote + let mut remote = repo.remote("target", &target.url)?; + // add refspecs (but wildcard not supported by libgit2) + repo.remote_add_push("target", "+refs/remotes/origin/*:refs/heads/*")?; + repo.remote_add_push("target", "+refs/tags/*:refs/tags/*")?; + + let mut refspecs: Vec = vec![]; + + // get remote origin branches + let branches = repo.branches(Some(BranchType::Remote))?; + // add refspecs + for b in branches.flatten() { + let name = b.0.name(); + if let Ok(name) = name { + if let Some(name) = name { + let branch = &name[name.find('/').unwrap()..]; + println!("sync branch {} to target repo", name); + refspecs.push(format!("+refs/remotes/{}:refs/heads{}", name, branch)); + } + } + } + + // get all tags + let tags = repo.tag_names(None)?; + // add refspecs + for tag in tags.iter().flatten() { + println!("sync tag {} to target repo", tag); + refspecs.push(format!("+refs/tags/{}:refs/tags/{}", tag, tag)); + } + + let mut po = push_options(target)?; + remote.push(&refspecs, Some(&mut po))?; + Ok(()) +}