[M] Move tests
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cli_config_opens_wizard() {
|
||||
let cli = Cli::try_parse_from(["git-sync", "config"]).unwrap();
|
||||
|
||||
assert!(matches!(cli.command, Command::Config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_rejects_removed_config_subcommands() {
|
||||
for args in [
|
||||
["git-sync", "config", "wizard"].as_slice(),
|
||||
["git-sync", "config", "init"].as_slice(),
|
||||
["git-sync", "config", "show"].as_slice(),
|
||||
["git-sync", "config", "site", "list"].as_slice(),
|
||||
["git-sync", "config", "mirror", "list"].as_slice(),
|
||||
] {
|
||||
assert!(Cli::try_parse_from(args).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_accepts_sync_repo_pattern() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"git-sync",
|
||||
"sync",
|
||||
"--repo-pattern",
|
||||
"^(foo|bar)-",
|
||||
"--dry-run",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let Command::Sync(args) = cli.command else {
|
||||
panic!("parsed unexpected command");
|
||||
};
|
||||
assert_eq!(args.repo_pattern, Some("^(foo|bar)-".to_string()));
|
||||
assert!(args.dry_run);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_accepts_sync_retry_failed() {
|
||||
let cli = Cli::try_parse_from(["git-sync", "sync", "--retry-failed"]).unwrap();
|
||||
|
||||
let Command::Sync(args) = cli.command else {
|
||||
panic!("parsed unexpected command");
|
||||
};
|
||||
assert!(args.retry_failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_accepts_sync_jobs() {
|
||||
let cli = Cli::try_parse_from(["git-sync", "sync", "--jobs", "8"]).unwrap();
|
||||
|
||||
let Command::Sync(args) = cli.command else {
|
||||
panic!("parsed unexpected command");
|
||||
};
|
||||
assert_eq!(args.jobs, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_accepts_webhook_serve() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"git-sync",
|
||||
"serve",
|
||||
"--listen",
|
||||
"127.0.0.1:9000",
|
||||
"--secret-env",
|
||||
"WEBHOOK_SECRET",
|
||||
"--jobs",
|
||||
"2",
|
||||
"--full-sync-interval-minutes",
|
||||
"30",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let Command::Serve(args) = cli.command else {
|
||||
panic!("parsed unexpected command");
|
||||
};
|
||||
assert_eq!(args.listen, "127.0.0.1:9000");
|
||||
assert_eq!(args.secret_env, Some("WEBHOOK_SECRET".to_string()));
|
||||
assert_eq!(args.jobs, 2);
|
||||
assert_eq!(args.full_sync_interval_minutes, Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_accepts_webhook_install() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"git-sync",
|
||||
"webhook",
|
||||
"install",
|
||||
"--url",
|
||||
"https://mirror.example.test/webhook",
|
||||
"--secret",
|
||||
"secret",
|
||||
"--group",
|
||||
"sync-1",
|
||||
"--repo-pattern",
|
||||
"^repo$",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let Command::Webhook(WebhookCommand::Install(args)) = cli.command else {
|
||||
panic!("parsed unexpected command");
|
||||
};
|
||||
assert_eq!(
|
||||
args.url,
|
||||
Some("https://mirror.example.test/webhook".to_string())
|
||||
);
|
||||
assert_eq!(args.secret, Some("secret".to_string()));
|
||||
assert_eq!(args.group, Some("sync-1".to_string()));
|
||||
assert_eq!(args.repo_pattern, Some("^repo$".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_accepts_webhook_uninstall() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"git-sync",
|
||||
"webhook",
|
||||
"uninstall",
|
||||
"--group",
|
||||
"sync-1",
|
||||
"--dry-run",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let Command::Webhook(WebhookCommand::Uninstall(args)) = cli.command else {
|
||||
panic!("parsed unexpected command");
|
||||
};
|
||||
assert_eq!(args.group, Some("sync-1".to_string()));
|
||||
assert!(args.dry_run);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_token_forms() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[webhook]
|
||||
install = true
|
||||
url = "https://mirror.example.test/webhook"
|
||||
secret = { env = "WEBHOOK_SECRET" }
|
||||
full_sync_interval_minutes = 60
|
||||
reachability_check_interval_minutes = 15
|
||||
|
||||
[[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);
|
||||
let webhook = config.webhook.unwrap();
|
||||
assert!(webhook.install);
|
||||
assert_eq!(webhook.url, "https://mirror.example.test/webhook");
|
||||
assert_eq!(
|
||||
webhook.secret,
|
||||
TokenConfig::Env("WEBHOOK_SECRET".to_string())
|
||||
);
|
||||
assert_eq!(webhook.full_sync_interval_minutes, Some(60));
|
||||
}
|
||||
|
||||
#[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,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
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,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
let err = validate_config(&config).unwrap_err().to_string();
|
||||
assert!(err.contains("unknown site 'missing'"));
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
assert_eq!(
|
||||
SiteConfig {
|
||||
base_url: "https://forgejo.example.test".to_string(),
|
||||
..site("forgejo", ProviderKind::Forgejo)
|
||||
}
|
||||
.api_base(),
|
||||
"https://forgejo.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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
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 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 ls_remote_snapshot_changes_when_remote_refs_change() {
|
||||
let fixture = GitFixture::new();
|
||||
fixture.commit("base", "base", 1_700_000_000);
|
||||
fixture.tag("v1");
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.push_tag(&fixture.remote_a, "v1");
|
||||
let remote = fixture.remotes().remove(0);
|
||||
let redactor = Redactor::new(Vec::new());
|
||||
|
||||
let first = ls_remote_refs(&remote, &redactor).unwrap();
|
||||
let unchanged = ls_remote_refs(&remote, &redactor).unwrap();
|
||||
assert_eq!(first, unchanged);
|
||||
assert_eq!(first.refs, 2);
|
||||
|
||||
fixture.commit("feature", "feature", 1_700_000_100);
|
||||
fixture.push_head(&fixture.remote_a, "feature");
|
||||
let changed = ls_remote_refs(&remote, &redactor).unwrap();
|
||||
|
||||
assert_ne!(first.hash, changed.hash);
|
||||
assert_eq!(changed.refs, 3);
|
||||
}
|
||||
|
||||
#[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_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 cached_remote_refs_match_ls_remote_snapshot_after_fetch() {
|
||||
let fixture = GitFixture::new();
|
||||
fixture.commit("base", "base", 1_700_000_000);
|
||||
fixture.tag("v1");
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.push_tag(&fixture.remote_a, "v1");
|
||||
|
||||
let mirror = fixture.mirror();
|
||||
let remote = fixture.remotes().remove(0);
|
||||
assert!(
|
||||
!mirror
|
||||
.cached_remote_refs_match(
|
||||
&remote,
|
||||
&ls_remote_refs(&remote, &Redactor::new(Vec::new())).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
mirror.fetch_remote(&remote).unwrap();
|
||||
let snapshot = ls_remote_refs(&remote, &Redactor::new(Vec::new())).unwrap();
|
||||
assert!(mirror.cached_remote_refs_match(&remote, &snapshot).unwrap());
|
||||
|
||||
fixture.commit("newer", "newer", 1_700_000_100);
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
let changed = ls_remote_refs(&remote, &Redactor::new(Vec::new())).unwrap();
|
||||
assert!(!mirror.cached_remote_refs_match(&remote, &changed).unwrap());
|
||||
}
|
||||
|
||||
#[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()]);
|
||||
assert_eq!(main.target_remotes, vec!["a".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 delete_branches_removes_branch_from_target_remotes() {
|
||||
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();
|
||||
mirror
|
||||
.delete_branches(
|
||||
&fixture.remotes(),
|
||||
&[BranchDeletion {
|
||||
branch: "main".to_string(),
|
||||
deleted_remotes: vec!["a".to_string()],
|
||||
target_remotes: vec!["b".to_string()],
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(fixture.remote_ref_exists(&fixture.remote_a, "refs/heads/main"));
|
||||
assert!(!fixture.remote_ref_exists(&fixture.remote_b, "refs/heads/main"));
|
||||
}
|
||||
|
||||
#[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!(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));
|
||||
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 remote_ref_exists(&self, remote: &Path, reference: &str) -> bool {
|
||||
git_command(
|
||||
None,
|
||||
[
|
||||
"--git-dir",
|
||||
remote.to_str().unwrap(),
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
reference,
|
||||
],
|
||||
)
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn wizard_builds_sync_group_from_profile_urls() {
|
||||
let input = [
|
||||
"https://github.com/hykilpikonna",
|
||||
"gh-token",
|
||||
"",
|
||||
"https://gitea.example.test/azalea",
|
||||
"gt-token",
|
||||
"",
|
||||
"n",
|
||||
"n",
|
||||
"3",
|
||||
]
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
let mut reader = Cursor::new(input.as_bytes());
|
||||
let mut output = Vec::new();
|
||||
|
||||
let config = run_config_wizard_with_io(Config::default(), &mut reader, &mut output).unwrap();
|
||||
|
||||
assert_eq!(config.sites.len(), 2);
|
||||
assert_eq!(config.sites[0].name, "github");
|
||||
assert_eq!(config.sites[0].provider, ProviderKind::Github);
|
||||
assert_eq!(config.sites[0].base_url, "https://github.com");
|
||||
assert_eq!(
|
||||
config.sites[0].token,
|
||||
TokenConfig::Value("gh-token".to_string())
|
||||
);
|
||||
assert_eq!(config.sites[1].name, "gitea-example-test");
|
||||
assert_eq!(config.sites[1].provider, ProviderKind::Gitea);
|
||||
assert_eq!(config.sites[1].base_url, "https://gitea.example.test");
|
||||
|
||||
assert_eq!(config.mirrors.len(), 1);
|
||||
assert_eq!(config.mirrors[0].name, "sync-1");
|
||||
assert_eq!(config.mirrors[0].endpoints.len(), 2);
|
||||
assert_eq!(config.mirrors[0].endpoints[0].site, "github");
|
||||
assert_eq!(config.mirrors[0].endpoints[0].kind, NamespaceKind::User);
|
||||
assert_eq!(config.mirrors[0].endpoints[0].namespace, "hykilpikonna");
|
||||
assert_eq!(config.mirrors[0].endpoints[1].site, "gitea-example-test");
|
||||
assert_eq!(config.mirrors[0].endpoints[1].namespace, "azalea");
|
||||
assert!(config.mirrors[0].create_missing);
|
||||
assert_eq!(config.mirrors[0].visibility, Visibility::Private);
|
||||
assert!(!config.mirrors[0].allow_force);
|
||||
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("1. github.com/hykilpikonna <-> gitea.example.test/azalea"));
|
||||
assert!(output.contains("Add another sync group"));
|
||||
assert!(output.contains("Delete an existing group"));
|
||||
assert!(output.contains("Done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_can_build_three_way_sync() {
|
||||
let input = [
|
||||
"https://github.com/alice",
|
||||
"gh-token",
|
||||
"",
|
||||
"https://gitlab.com/alice",
|
||||
"gl-token",
|
||||
"",
|
||||
"y",
|
||||
"https://gitea.example.test/alice",
|
||||
"gt-token",
|
||||
"",
|
||||
"n",
|
||||
"n",
|
||||
"3",
|
||||
]
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
let mut reader = Cursor::new(input.as_bytes());
|
||||
let mut output = Vec::new();
|
||||
|
||||
let config = run_config_wizard_with_io(Config::default(), &mut reader, &mut output).unwrap();
|
||||
|
||||
assert_eq!(config.mirrors.len(), 1);
|
||||
assert_eq!(config.mirrors[0].endpoints.len(), 3);
|
||||
assert_eq!(config.sites.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_can_enable_webhooks() {
|
||||
let input = [
|
||||
"https://github.com/alice",
|
||||
"gh-token",
|
||||
"",
|
||||
"https://gitea.example.test/alice",
|
||||
"gt-token",
|
||||
"",
|
||||
"n",
|
||||
"y",
|
||||
"https://mirror.example.test/webhook",
|
||||
"y",
|
||||
"30",
|
||||
"3",
|
||||
]
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
let mut reader = Cursor::new(input.as_bytes());
|
||||
let mut output = Vec::new();
|
||||
|
||||
let config = run_config_wizard_with_io(Config::default(), &mut reader, &mut output).unwrap();
|
||||
|
||||
let webhook = config.webhook.unwrap();
|
||||
assert!(webhook.install);
|
||||
assert_eq!(webhook.url, "https://mirror.example.test/webhook");
|
||||
assert_eq!(webhook.full_sync_interval_minutes, Some(30));
|
||||
assert_eq!(webhook.reachability_check_interval_minutes, Some(15));
|
||||
assert_eq!(
|
||||
webhook.secret,
|
||||
TokenConfig::Value("test-webhook-secret".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_reuses_existing_credentials_for_same_instance() {
|
||||
let config = Config {
|
||||
sites: vec![SiteConfig {
|
||||
name: "github".to_string(),
|
||||
provider: ProviderKind::Github,
|
||||
base_url: "https://github.com".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("existing".to_string()),
|
||||
git_username: None,
|
||||
}],
|
||||
mirrors: Vec::new(),
|
||||
webhook: None,
|
||||
};
|
||||
let input = [
|
||||
"https://github.com/alice",
|
||||
"",
|
||||
"https://github.com/bob",
|
||||
"",
|
||||
"n",
|
||||
"n",
|
||||
"3",
|
||||
]
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
let mut reader = Cursor::new(input.as_bytes());
|
||||
let mut output = Vec::new();
|
||||
|
||||
let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap();
|
||||
|
||||
assert_eq!(updated.sites.len(), 1);
|
||||
assert_eq!(updated.mirrors[0].endpoints[0].site, "github");
|
||||
assert_eq!(updated.mirrors[0].endpoints[1].site, "github");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_starts_existing_config_at_sync_group_menu() {
|
||||
let config = Config {
|
||||
sites: vec![
|
||||
SiteConfig {
|
||||
name: "github".to_string(),
|
||||
provider: ProviderKind::Github,
|
||||
base_url: "https://github.com".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("existing-gh".to_string()),
|
||||
git_username: None,
|
||||
},
|
||||
SiteConfig {
|
||||
name: "gitea".to_string(),
|
||||
provider: ProviderKind::Gitea,
|
||||
base_url: "https://gitea.example.test".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("existing-gt".to_string()),
|
||||
git_username: None,
|
||||
},
|
||||
],
|
||||
mirrors: vec![MirrorConfig {
|
||||
name: "sync-1".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,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
let mut reader = Cursor::new(b"3\n".as_slice());
|
||||
let mut output = Vec::new();
|
||||
|
||||
let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap();
|
||||
|
||||
assert_eq!(updated.mirrors.len(), 1);
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("1. github.com/alice <-> gitea.example.test/alice"));
|
||||
assert!(output.contains("What would you like to do?"));
|
||||
assert!(!output.contains("Profile/org URL:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_deletes_existing_sync_group_from_menu() {
|
||||
let config = Config {
|
||||
sites: vec![
|
||||
SiteConfig {
|
||||
name: "github".to_string(),
|
||||
provider: ProviderKind::Github,
|
||||
base_url: "https://github.com".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("existing-gh".to_string()),
|
||||
git_username: None,
|
||||
},
|
||||
SiteConfig {
|
||||
name: "gitea".to_string(),
|
||||
provider: ProviderKind::Gitea,
|
||||
base_url: "https://gitea.example.test".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("existing-gt".to_string()),
|
||||
git_username: None,
|
||||
},
|
||||
],
|
||||
mirrors: vec![MirrorConfig {
|
||||
name: "sync-1".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,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
let input = ["2", "1", "3"].join("\n") + "\n";
|
||||
let mut reader = Cursor::new(input.as_bytes());
|
||||
let mut output = Vec::new();
|
||||
|
||||
let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap();
|
||||
|
||||
assert!(updated.mirrors.is_empty());
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("Delete sync group"));
|
||||
assert!(output.contains("2. Back"));
|
||||
assert!(output.contains("deleted sync group 1"));
|
||||
assert!(output.contains("No sync groups configured."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_can_go_back_from_delete_menu() {
|
||||
let config = Config {
|
||||
sites: vec![
|
||||
SiteConfig {
|
||||
name: "github".to_string(),
|
||||
provider: ProviderKind::Github,
|
||||
base_url: "https://github.com".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("existing-gh".to_string()),
|
||||
git_username: None,
|
||||
},
|
||||
SiteConfig {
|
||||
name: "gitea".to_string(),
|
||||
provider: ProviderKind::Gitea,
|
||||
base_url: "https://gitea.example.test".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("existing-gt".to_string()),
|
||||
git_username: None,
|
||||
},
|
||||
],
|
||||
mirrors: vec![MirrorConfig {
|
||||
name: "sync-1".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,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
let input = ["2", "2", "3"].join("\n") + "\n";
|
||||
let mut reader = Cursor::new(input.as_bytes());
|
||||
let mut output = Vec::new();
|
||||
|
||||
let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap();
|
||||
|
||||
assert_eq!(updated.mirrors.len(), 1);
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("2. Back"));
|
||||
assert!(!output.contains("deleted sync group"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_reports_eof_instead_of_looping() {
|
||||
let mut reader = Cursor::new(b"".as_slice());
|
||||
let mut output = Vec::new();
|
||||
|
||||
let err = run_config_wizard_with_io(Config::default(), &mut reader, &mut output)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("unexpected end of input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_urls_are_parsed_into_base_and_namespace() {
|
||||
let parsed = parse_profile_url("github.com/alice").unwrap();
|
||||
assert_eq!(parsed.base_url, "https://github.com");
|
||||
assert_eq!(parsed.host, "github.com");
|
||||
assert_eq!(parsed.namespace, "alice");
|
||||
|
||||
let parsed = parse_profile_url("https://gitlab.example.test:8443/groups/team").unwrap();
|
||||
assert_eq!(parsed.base_url, "https://gitlab.example.test:8443");
|
||||
assert_eq!(parsed.namespace, "groups/team");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_names_are_derived_from_urls_and_made_unique() {
|
||||
let mut config = Config::default();
|
||||
assert_eq!(
|
||||
default_site_name(&config, "https://github.com", &ProviderKind::Github),
|
||||
"github"
|
||||
);
|
||||
assert_eq!(
|
||||
default_site_name(
|
||||
&config,
|
||||
"https://git.my-company.com:3000",
|
||||
&ProviderKind::Gitea
|
||||
),
|
||||
"git-my-company"
|
||||
);
|
||||
|
||||
config.upsert_site(SiteConfig {
|
||||
name: "github".to_string(),
|
||||
provider: ProviderKind::Github,
|
||||
base_url: "https://github.com".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("token".to_string()),
|
||||
git_username: None,
|
||||
});
|
||||
assert_eq!(
|
||||
default_site_name(&config, "https://github.com", &ProviderKind::Github),
|
||||
"github-2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_creation_urls_are_provider_specific() {
|
||||
assert_eq!(
|
||||
token_creation_url(&ProviderKind::Github, "https://github.com/"),
|
||||
"https://github.com/settings/tokens"
|
||||
);
|
||||
assert_eq!(
|
||||
token_creation_url(&ProviderKind::Gitlab, "https://gitlab.example.test"),
|
||||
"https://gitlab.example.test/-/user_settings/personal_access_tokens?name=git-sync&scopes=api"
|
||||
);
|
||||
assert_eq!(
|
||||
token_creation_url(&ProviderKind::Gitea, "gitea.example.test"),
|
||||
"https://gitea.example.test/user/settings/applications"
|
||||
);
|
||||
assert_eq!(
|
||||
token_creation_url(&ProviderKind::Forgejo, "forgejo.example.test"),
|
||||
"https://forgejo.example.test/user/settings/applications"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
use super::*;
|
||||
use anyhow::bail;
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
pub fn run_config_wizard_with_io<R, W>(
|
||||
mut config: Config,
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
) -> Result<Config>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
writeln!(writer, "git-sync configuration wizard")?;
|
||||
if config.mirrors.is_empty() {
|
||||
add_sync_group(reader, writer, &mut config)?;
|
||||
write_sync_groups(&config, writer)?;
|
||||
} else {
|
||||
write_sync_groups(&config, writer)?;
|
||||
}
|
||||
|
||||
loop {
|
||||
match prompt_wizard_action(reader, writer)? {
|
||||
WizardAction::AddSyncGroup => {
|
||||
add_sync_group(reader, writer, &mut config)?;
|
||||
write_sync_groups(&config, writer)?;
|
||||
}
|
||||
WizardAction::DeleteSyncGroup => {
|
||||
if delete_sync_group(reader, writer, &mut config)? {
|
||||
write_sync_groups(&config, writer)?;
|
||||
}
|
||||
}
|
||||
WizardAction::Done => break,
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn add_sync_group<R, W>(reader: &mut R, writer: &mut W, config: &mut Config) -> Result<()>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
let mut endpoints = Vec::new();
|
||||
let first = prompt_target(reader, writer, "Profile/org URL")?;
|
||||
endpoints.push(ensure_credentials(config, first, reader, writer)?);
|
||||
let second = prompt_target(reader, writer, "Profile/org URL to sync with")?;
|
||||
endpoints.push(ensure_credentials(config, second, reader, writer)?);
|
||||
|
||||
while prompt_bool(
|
||||
reader,
|
||||
writer,
|
||||
"Add a third endpoint for 3-way sync?",
|
||||
false,
|
||||
)? {
|
||||
let next = prompt_target(reader, writer, "Additional profile/org URL")?;
|
||||
endpoints.push(ensure_credentials(config, next, reader, writer)?);
|
||||
}
|
||||
|
||||
config.upsert_mirror(MirrorConfig {
|
||||
name: next_mirror_name(config),
|
||||
endpoints,
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
});
|
||||
prompt_webhook_setup(reader, writer, config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_webhook_setup<R, W>(reader: &mut R, writer: &mut W, config: &mut Config) -> Result<()>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
if config
|
||||
.webhook
|
||||
.as_ref()
|
||||
.is_some_and(|webhook| webhook.install)
|
||||
{
|
||||
writeln!(writer, "Webhooks already enabled.")?;
|
||||
return Ok(());
|
||||
}
|
||||
writeln!(
|
||||
writer,
|
||||
"Install webhooks? Strongly recommended because immediate sync greatly reduces conflicts."
|
||||
)?;
|
||||
if !prompt_bool(reader, writer, "Install webhook?", true)? {
|
||||
return Ok(());
|
||||
}
|
||||
let url = prompt_required(reader, writer, "Webhook URL reachable by providers")?;
|
||||
if let Err(error) = validate_url(&url) {
|
||||
bail!(error);
|
||||
}
|
||||
let full_sync_interval_minutes = if prompt_bool(
|
||||
reader,
|
||||
writer,
|
||||
"Run periodic full sync while serve is running?",
|
||||
true,
|
||||
)? {
|
||||
Some(
|
||||
prompt_with_default(reader, writer, "Full sync interval in minutes", "60")?
|
||||
.parse::<u64>()
|
||||
.context("full sync interval must be a number")?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
config.webhook = Some(WebhookConfig {
|
||||
install: true,
|
||||
url,
|
||||
secret: TokenConfig::Value("test-webhook-secret".to_string()),
|
||||
full_sync_interval_minutes,
|
||||
reachability_check_interval_minutes: Some(15),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_wizard_action<R, W>(reader: &mut R, writer: &mut W) -> Result<WizardAction>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
loop {
|
||||
writeln!(writer, "What would you like to do?")?;
|
||||
writeln!(writer, " 1. Add another sync group")?;
|
||||
writeln!(writer, " 2. Delete an existing group")?;
|
||||
writeln!(writer, " 3. Done")?;
|
||||
write!(writer, "Choose an option: ")?;
|
||||
writer.flush()?;
|
||||
let value = read_line(reader)?.trim().to_ascii_lowercase();
|
||||
match value.as_str() {
|
||||
"1" | "add" | "add another sync group" => return Ok(WizardAction::AddSyncGroup),
|
||||
"2" | "delete" | "delete an existing group" => {
|
||||
return Ok(WizardAction::DeleteSyncGroup);
|
||||
}
|
||||
"3" | "done" | "finish" => return Ok(WizardAction::Done),
|
||||
_ => writeln!(writer, "Enter 1, 2, or 3.")?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_sync_group<R, W>(reader: &mut R, writer: &mut W, config: &mut Config) -> Result<bool>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
if config.mirrors.is_empty() {
|
||||
writeln!(writer, "No sync groups to delete.")?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
loop {
|
||||
writeln!(writer, "Delete sync group")?;
|
||||
for (index, option) in sync_group_summaries(config).iter().enumerate() {
|
||||
writeln!(writer, " {}. {}", index + 1, option)?;
|
||||
}
|
||||
writeln!(writer, " {}. Back", config.mirrors.len() + 1)?;
|
||||
write!(writer, "Choose a sync group: ")?;
|
||||
writer.flush()?;
|
||||
let value = read_line(reader)?.trim().to_ascii_lowercase();
|
||||
if value == "b" || value == "back" {
|
||||
return Ok(false);
|
||||
}
|
||||
match value.parse::<usize>() {
|
||||
Ok(index) if (1..=config.mirrors.len()).contains(&index) => {
|
||||
let name = config.mirrors[index - 1].name.clone();
|
||||
config.remove_mirror(&name)?;
|
||||
writeln!(writer, "deleted sync group {index}")?;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(index) if index == config.mirrors.len() + 1 => return Ok(false),
|
||||
_ => writeln!(writer, "Enter a sync group number, or choose Back.")?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_target<R, W>(reader: &mut R, writer: &mut W, prompt: &str) -> Result<ProfileTarget>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
let url = prompt_required(reader, writer, prompt)?;
|
||||
let parsed = parse_profile_url(&url)?;
|
||||
let provider = known_provider_from_host(&parsed.host).unwrap_or_else(|| {
|
||||
prompt_provider(reader, writer, &parsed.base_url).expect("provider prompt failed")
|
||||
});
|
||||
Ok(ProfileTarget {
|
||||
base_url: parsed.base_url,
|
||||
provider,
|
||||
namespace: parsed.namespace,
|
||||
kind: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_credentials<R, W>(
|
||||
config: &mut Config,
|
||||
target: ProfileTarget,
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
) -> Result<EndpointConfig>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
if let Some(site) = config.sites.iter().find(|site| {
|
||||
site.provider == target.provider
|
||||
&& trim_url_end(&site.base_url) == trim_url_end(&target.base_url)
|
||||
}) {
|
||||
let kind = target.kind.clone().unwrap_or_else(|| {
|
||||
prompt_namespace_kind(reader, writer, &target.namespace).expect("kind prompt failed")
|
||||
});
|
||||
let endpoint = target_endpoint(&target, kind, site.name.clone());
|
||||
writeln!(
|
||||
writer,
|
||||
"Using existing credentials for {}",
|
||||
target_display(&target)
|
||||
)?;
|
||||
return Ok(endpoint);
|
||||
}
|
||||
|
||||
for line in pat_instruction_lines(&target.provider, &target.base_url) {
|
||||
writeln!(writer, "{line}")?;
|
||||
}
|
||||
let token = prompt_required(reader, writer, "PAT token")?;
|
||||
let site = SiteConfig {
|
||||
name: default_site_name(config, &target.base_url, &target.provider),
|
||||
provider: target.provider.clone(),
|
||||
base_url: target.base_url.clone(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value(token),
|
||||
git_username: None,
|
||||
};
|
||||
let site_name = site.name.clone();
|
||||
config.upsert_site(site);
|
||||
let kind = target.kind.clone().unwrap_or_else(|| {
|
||||
prompt_namespace_kind(reader, writer, &target.namespace).expect("kind prompt failed")
|
||||
});
|
||||
Ok(target_endpoint(&target, kind, site_name))
|
||||
}
|
||||
|
||||
fn prompt_provider<R, W>(reader: &mut R, writer: &mut W, base_url: &str) -> Result<ProviderKind>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
loop {
|
||||
let value = prompt_required(reader, writer, &format!("Provider for {base_url}"))?;
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"github" => return Ok(ProviderKind::Github),
|
||||
"gitlab" => return Ok(ProviderKind::Gitlab),
|
||||
"gitea" => return Ok(ProviderKind::Gitea),
|
||||
"forgejo" => return Ok(ProviderKind::Forgejo),
|
||||
_ => writeln!(
|
||||
writer,
|
||||
"Provider must be github, gitlab, gitea, or forgejo."
|
||||
)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_namespace_kind<R, W>(
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
namespace: &str,
|
||||
) -> Result<NamespaceKind>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
loop {
|
||||
let value = prompt_with_default(reader, writer, &format!("What is {namespace}?"), "user")?;
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"user" => return Ok(NamespaceKind::User),
|
||||
"org" | "organization" => return Ok(NamespaceKind::Org),
|
||||
"group" => return Ok(NamespaceKind::Group),
|
||||
_ => writeln!(writer, "Namespace kind must be user, org, or group.")?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_sync_groups<W>(config: &Config, writer: &mut W) -> Result<()>
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
writeln!(writer, "Sync groups")?;
|
||||
if config.mirrors.is_empty() {
|
||||
writeln!(writer, "No sync groups configured.")?;
|
||||
return Ok(());
|
||||
}
|
||||
for (index, mirror) in config.mirrors.iter().enumerate() {
|
||||
writeln!(
|
||||
writer,
|
||||
"{}. {}",
|
||||
index + 1,
|
||||
sync_group_summary(config, mirror)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_bool<R, W>(reader: &mut R, writer: &mut W, label: &str, default: bool) -> Result<bool>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
let default_label = if default { "Y/n" } else { "y/N" };
|
||||
loop {
|
||||
write!(writer, "{label} [{default_label}]: ")?;
|
||||
writer.flush()?;
|
||||
let value = read_line(reader)?.trim().to_ascii_lowercase();
|
||||
match value.as_str() {
|
||||
"" => return Ok(default),
|
||||
"y" | "yes" | "true" => return Ok(true),
|
||||
"n" | "no" | "false" => return Ok(false),
|
||||
_ => writeln!(writer, "Enter yes or no.")?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_required<R, W>(reader: &mut R, writer: &mut W, label: &str) -> Result<String>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
loop {
|
||||
write!(writer, "{label}: ")?;
|
||||
writer.flush()?;
|
||||
let value = read_line(reader)?.trim().to_string();
|
||||
if !value.is_empty() {
|
||||
return Ok(value);
|
||||
}
|
||||
writeln!(writer, "A value is required.")?;
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_with_default<R, W>(
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
label: &str,
|
||||
default: &str,
|
||||
) -> Result<String>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
write!(writer, "{label} [{default}]: ")?;
|
||||
writer.flush()?;
|
||||
let value = read_line(reader)?.trim().to_string();
|
||||
if value.is_empty() {
|
||||
Ok(default.to_string())
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_line<R>(reader: &mut R) -> Result<String>
|
||||
where
|
||||
R: BufRead,
|
||||
{
|
||||
let mut value = String::new();
|
||||
let bytes = reader.read_line(&mut value)?;
|
||||
if bytes == 0 {
|
||||
bail!("unexpected end of input while reading interactive configuration");
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repo_prefix_pads_and_truncates_to_fixed_width() {
|
||||
assert_eq!(repo_prefix("api", 6), "api ");
|
||||
assert_eq!(repo_prefix("very-long-repo", 8), "very-lo~");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_text_truncates_to_fixed_width() {
|
||||
assert_eq!(truncate_status("short", 8), "short");
|
||||
assert_eq!(truncate_status("very-long-status", 8), "very-lo~");
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
use super::*;
|
||||
use crate::config::TokenConfig;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
#[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"
|
||||
);
|
||||
|
||||
let forgejo_site = site(ProviderKind::Forgejo, None);
|
||||
let forgejo = ProviderClient::new(&forgejo_site).unwrap();
|
||||
assert_eq!(
|
||||
forgejo
|
||||
.authenticated_clone_url("https://forgejo.example.test/alice/repo.git")
|
||||
.unwrap(),
|
||||
"https://oauth2:secret@forgejo.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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_token_checks_user_endpoint_with_provider_auth_header() {
|
||||
let (api_url, handle) = one_request_server("200 OK", "{}", |request| {
|
||||
assert!(request.starts_with("GET /user "), "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)
|
||||
};
|
||||
|
||||
ProviderClient::new(&site)
|
||||
.unwrap()
|
||||
.validate_token()
|
||||
.unwrap();
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_token_reports_provider_rejection() {
|
||||
let (api_url, handle) = one_request_server("401 Unauthorized", "bad token", |request| {
|
||||
assert!(request.starts_with("GET /user "), "request was {request}");
|
||||
assert!(
|
||||
request
|
||||
.to_ascii_lowercase()
|
||||
.contains("private-token: secret"),
|
||||
"request was {request}"
|
||||
);
|
||||
});
|
||||
let site = SiteConfig {
|
||||
api_url: Some(api_url),
|
||||
..site(ProviderKind::Gitlab, None)
|
||||
};
|
||||
|
||||
let err = ProviderClient::new(&site)
|
||||
.unwrap()
|
||||
.validate_token()
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("401 Unauthorized"));
|
||||
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();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_webhook_posts_github_hook_when_missing() {
|
||||
let (api_url, handle) = request_server(
|
||||
vec![("200 OK", "[]"), ("201 Created", r#"{"id":1}"#)],
|
||||
|index, request| match index {
|
||||
0 => assert!(
|
||||
request.starts_with("GET /repos/alice/repo/hooks "),
|
||||
"request was {request}"
|
||||
),
|
||||
1 => {
|
||||
assert!(
|
||||
request.starts_with("POST /repos/alice/repo/hooks "),
|
||||
"request was {request}"
|
||||
);
|
||||
assert!(request.contains("https://mirror.example.test/webhook"));
|
||||
assert!(request.contains("secret"));
|
||||
assert!(request.contains("push"));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
},
|
||||
);
|
||||
let site = SiteConfig {
|
||||
api_url: Some(api_url),
|
||||
..site(ProviderKind::Github, None)
|
||||
};
|
||||
let client = ProviderClient::new(&site).unwrap();
|
||||
|
||||
client
|
||||
.install_webhook(
|
||||
&EndpointConfig {
|
||||
site: "github".to_string(),
|
||||
kind: NamespaceKind::User,
|
||||
namespace: "alice".to_string(),
|
||||
},
|
||||
&RemoteRepo {
|
||||
name: "repo".to_string(),
|
||||
clone_url: "https://github.com/alice/repo.git".to_string(),
|
||||
private: true,
|
||||
description: None,
|
||||
},
|
||||
"https://mirror.example.test/webhook",
|
||||
"secret",
|
||||
)
|
||||
.unwrap();
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_webhook_deletes_matching_github_hook() {
|
||||
let (api_url, handle) = request_server(
|
||||
vec![
|
||||
(
|
||||
"200 OK",
|
||||
r#"[{"id":42,"config":{"url":"https://mirror.example.test/webhook"}}]"#,
|
||||
),
|
||||
("204 No Content", ""),
|
||||
],
|
||||
|index, request| match index {
|
||||
0 => assert!(
|
||||
request.starts_with("GET /repos/alice/repo/hooks "),
|
||||
"request was {request}"
|
||||
),
|
||||
1 => assert!(
|
||||
request.starts_with("DELETE /repos/alice/repo/hooks/42 "),
|
||||
"request was {request}"
|
||||
),
|
||||
_ => unreachable!(),
|
||||
},
|
||||
);
|
||||
let site = SiteConfig {
|
||||
api_url: Some(api_url),
|
||||
..site(ProviderKind::Github, None)
|
||||
};
|
||||
let client = ProviderClient::new(&site).unwrap();
|
||||
|
||||
let removed = client
|
||||
.uninstall_webhook(
|
||||
&EndpointConfig {
|
||||
site: "github".to_string(),
|
||||
kind: NamespaceKind::User,
|
||||
namespace: "alice".to_string(),
|
||||
},
|
||||
"repo",
|
||||
"https://mirror.example.test/webhook",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(removed);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn one_request_server<F>(
|
||||
status: &'static str,
|
||||
body: &'static str,
|
||||
assert_request: F,
|
||||
) -> (String, thread::JoinHandle<()>)
|
||||
where
|
||||
F: FnOnce(&str) + Send + 'static,
|
||||
{
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let bytes = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..bytes]).to_string();
|
||||
assert_request(&request);
|
||||
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
(format!("http://{address}"), handle)
|
||||
}
|
||||
|
||||
fn request_server<F>(
|
||||
responses: Vec<(&'static str, &'static str)>,
|
||||
mut assert_request: F,
|
||||
) -> (String, thread::JoinHandle<()>)
|
||||
where
|
||||
F: FnMut(usize, &str) + Send + 'static,
|
||||
{
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let handle = thread::spawn(move || {
|
||||
for (index, (status, body)) in responses.into_iter().enumerate() {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let bytes = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..bytes]).to_string();
|
||||
assert_request(index, &request);
|
||||
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\nconnection: close\r\ncontent-length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
(format!("http://{address}"), handle)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failure_state_persists_repo_failures_by_group() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let failures = vec![
|
||||
SyncFailure::repo(
|
||||
"sync-1".to_string(),
|
||||
"repo-a".to_string(),
|
||||
anyhow::anyhow!("a"),
|
||||
),
|
||||
SyncFailure::repo(
|
||||
"sync-1".to_string(),
|
||||
"repo-a".to_string(),
|
||||
anyhow::anyhow!("a again"),
|
||||
),
|
||||
SyncFailure::repo(
|
||||
"sync-2".to_string(),
|
||||
"repo-b".to_string(),
|
||||
anyhow::anyhow!("b"),
|
||||
),
|
||||
SyncFailure::group(
|
||||
"mirror group sync-3".to_string(),
|
||||
anyhow::anyhow!("list failed"),
|
||||
),
|
||||
];
|
||||
let state = FailureState::from_failures(&failures);
|
||||
|
||||
save_failure_state(temp.path(), &state).unwrap();
|
||||
let loaded = load_failure_state(temp.path()).unwrap();
|
||||
let by_group = loaded.repos_by_group();
|
||||
|
||||
assert_eq!(by_group["sync-1"].len(), 1);
|
||||
assert!(by_group["sync-1"].contains("repo-a"));
|
||||
assert_eq!(by_group["sync-2"].len(), 1);
|
||||
assert!(by_group["sync-2"].contains("repo-b"));
|
||||
assert!(!by_group.contains_key("sync-3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_failure_state_removes_retry_file() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state = FailureState {
|
||||
repos: vec![FailedRepo {
|
||||
group: "sync-1".to_string(),
|
||||
repo: "repo-a".to_string(),
|
||||
}],
|
||||
};
|
||||
save_failure_state(temp.path(), &state).unwrap();
|
||||
assert!(failure_state_path(temp.path()).exists());
|
||||
|
||||
save_failure_state(temp.path(), &FailureState::default()).unwrap();
|
||||
|
||||
assert!(!failure_state_path(temp.path()).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_state_persists_and_requires_exact_remote_ref_match() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let mut refs = BTreeMap::new();
|
||||
refs.insert(
|
||||
"github_alice".to_string(),
|
||||
remote_ref_state("abc", &[("main", "111")]),
|
||||
);
|
||||
refs.insert(
|
||||
"gitea_alice".to_string(),
|
||||
remote_ref_state("def", &[("main", "111")]),
|
||||
);
|
||||
let mut state = RefState::default();
|
||||
state.set_repo("sync-1", "repo-a", refs.clone());
|
||||
|
||||
save_ref_state(temp.path(), &state).unwrap();
|
||||
let loaded = load_ref_state(temp.path()).unwrap();
|
||||
|
||||
assert!(loaded.repo_matches("sync-1", "repo-a", &refs));
|
||||
|
||||
let mut changed_hash = refs.clone();
|
||||
changed_hash.insert(
|
||||
"github_alice".to_string(),
|
||||
remote_ref_state("changed", &[("main", "111")]),
|
||||
);
|
||||
assert!(!loaded.repo_matches("sync-1", "repo-a", &changed_hash));
|
||||
|
||||
let mut missing_remote = refs;
|
||||
missing_remote.remove("gitea_alice");
|
||||
assert!(!loaded.repo_matches("sync-1", "repo-a", &missing_remote));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_deletion_decisions_propagate_previous_synced_branch_deletion() {
|
||||
let remotes = test_remotes();
|
||||
let mut previous = BTreeMap::new();
|
||||
previous.insert(
|
||||
"github".to_string(),
|
||||
remote_ref_state("a", &[("main", "111")]),
|
||||
);
|
||||
previous.insert(
|
||||
"gitea".to_string(),
|
||||
remote_ref_state("b", &[("main", "111")]),
|
||||
);
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert("github".to_string(), remote_ref_state("c", &[]));
|
||||
current.insert(
|
||||
"gitea".to_string(),
|
||||
remote_ref_state("d", &[("main", "111")]),
|
||||
);
|
||||
|
||||
let (deletions, conflicts, blocked) =
|
||||
branch_deletion_decisions(&remotes, Some(&previous), ¤t);
|
||||
|
||||
assert!(conflicts.is_empty());
|
||||
assert!(blocked.contains("main"));
|
||||
assert_eq!(deletions.len(), 1);
|
||||
assert_eq!(deletions[0].branch, "main");
|
||||
assert_eq!(deletions[0].deleted_remotes, vec!["github".to_string()]);
|
||||
assert_eq!(deletions[0].target_remotes, vec!["gitea".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_deletion_decisions_conflict_when_branch_changed_elsewhere() {
|
||||
let remotes = test_remotes();
|
||||
let mut previous = BTreeMap::new();
|
||||
previous.insert(
|
||||
"github".to_string(),
|
||||
remote_ref_state("a", &[("main", "111")]),
|
||||
);
|
||||
previous.insert(
|
||||
"gitea".to_string(),
|
||||
remote_ref_state("b", &[("main", "111")]),
|
||||
);
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert("github".to_string(), remote_ref_state("c", &[]));
|
||||
current.insert(
|
||||
"gitea".to_string(),
|
||||
remote_ref_state("d", &[("main", "222")]),
|
||||
);
|
||||
|
||||
let (deletions, conflicts, blocked) =
|
||||
branch_deletion_decisions(&remotes, Some(&previous), ¤t);
|
||||
|
||||
assert!(deletions.is_empty());
|
||||
assert!(blocked.contains("main"));
|
||||
assert_eq!(conflicts.len(), 1);
|
||||
assert_eq!(conflicts[0].branch, "main");
|
||||
assert_eq!(conflicts[0].deleted_remotes, vec!["github".to_string()]);
|
||||
assert_eq!(conflicts[0].changed_remotes, vec!["gitea".to_string()]);
|
||||
}
|
||||
|
||||
fn remote_ref_state(hash: &str, branches: &[(&str, &str)]) -> RemoteRefState {
|
||||
RemoteRefState {
|
||||
hash: hash.to_string(),
|
||||
refs: branches.len(),
|
||||
branches: branches
|
||||
.iter()
|
||||
.map(|(branch, sha)| ((*branch).to_string(), (*sha).to_string()))
|
||||
.collect(),
|
||||
tags: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_remotes() -> Vec<RemoteSpec> {
|
||||
vec![
|
||||
RemoteSpec {
|
||||
name: "github".to_string(),
|
||||
url: "https://github.invalid/alice/repo.git".to_string(),
|
||||
display: "github:alice:User".to_string(),
|
||||
},
|
||||
RemoteSpec {
|
||||
name: "gitea".to_string(),
|
||||
url: "https://gitea.invalid/alice/repo.git".to_string(),
|
||||
display: "gitea:alice:User".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use super::*;
|
||||
use crate::config::{
|
||||
EndpointConfig, MirrorConfig, NamespaceKind, SiteConfig, TokenConfig, Visibility,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn verifies_github_hmac_signature() {
|
||||
let body = br#"{"repository":{"name":"repo"}}"#;
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("x-github-event".to_string(), "push".to_string());
|
||||
headers.insert(
|
||||
"x-hub-signature-256".to_string(),
|
||||
format!("sha256={}", hmac_sha256_hex(b"secret", body)),
|
||||
);
|
||||
|
||||
assert!(verify_signature(
|
||||
Some(&ProviderKind::Github),
|
||||
&headers,
|
||||
body,
|
||||
"secret"
|
||||
));
|
||||
assert!(!verify_signature(
|
||||
Some(&ProviderKind::Github),
|
||||
&headers,
|
||||
body,
|
||||
"wrong"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_github_push_payload() {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("x-github-event".to_string(), "push".to_string());
|
||||
let value: Value = serde_json::from_str(
|
||||
r#"{"repository":{"name":"repo","full_name":"alice/repo","owner":{"login":"alice"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let event = parse_event(Some(ProviderKind::Github), &headers, &value).unwrap();
|
||||
|
||||
assert_eq!(event.repo, "repo");
|
||||
assert_eq!(event.namespace.as_deref(), Some("alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_forgejo_push_payload() {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("x-forgejo-event".to_string(), "push".to_string());
|
||||
let value: Value = serde_json::from_str(
|
||||
r#"{"repository":{"name":"repo","full_name":"azalea/repo","owner":{"username":"azalea"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let provider = detect_provider(&headers);
|
||||
let event = parse_event(provider.clone(), &headers, &value).unwrap();
|
||||
|
||||
assert_eq!(provider, Some(ProviderKind::Forgejo));
|
||||
assert_eq!(event.repo, "repo");
|
||||
assert_eq!(event.namespace.as_deref(), Some("azalea"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifies_forgejo_hmac_signature() {
|
||||
let body = br#"{"repository":{"name":"repo"}}"#;
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("x-forgejo-event".to_string(), "push".to_string());
|
||||
headers.insert(
|
||||
"x-forgejo-signature".to_string(),
|
||||
format!("sha256={}", hmac_sha256_hex(b"secret", body)),
|
||||
);
|
||||
|
||||
assert!(verify_signature(
|
||||
Some(&ProviderKind::Forgejo),
|
||||
&headers,
|
||||
body,
|
||||
"secret"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_gitlab_push_payload() {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("x-gitlab-event".to_string(), "Push Hook".to_string());
|
||||
let value: Value = serde_json::from_str(
|
||||
r#"{"project":{"path":"repo","path_with_namespace":"parent/alice/repo"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let event = parse_event(Some(ProviderKind::Gitlab), &headers, &value).unwrap();
|
||||
|
||||
assert_eq!(event.repo, "repo");
|
||||
assert_eq!(event.namespace.as_deref(), Some("parent/alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_jobs_by_provider_and_namespace() {
|
||||
let config = Config {
|
||||
sites: vec![
|
||||
site("github", ProviderKind::Github),
|
||||
site("gitea", ProviderKind::Gitea),
|
||||
],
|
||||
mirrors: vec![MirrorConfig {
|
||||
name: "sync-1".to_string(),
|
||||
endpoints: vec![
|
||||
endpoint("github", NamespaceKind::User, "alice"),
|
||||
endpoint("gitea", NamespaceKind::User, "azalea"),
|
||||
],
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
let event = WebhookEvent {
|
||||
provider: Some(ProviderKind::Github),
|
||||
repo: "repo".to_string(),
|
||||
namespace: Some("alice".to_string()),
|
||||
};
|
||||
|
||||
let jobs = matching_jobs(&config, &event);
|
||||
|
||||
assert_eq!(jobs.len(), 1);
|
||||
assert_eq!(jobs[0].group, "sync-1");
|
||||
assert_eq!(jobs[0].repo, "repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_state_persists_installations() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let endpoint = endpoint("github", NamespaceKind::User, "alice");
|
||||
let key = webhook_installation_key("sync-1", &endpoint, "repo");
|
||||
let mut state = WebhookState::default();
|
||||
state.installations.insert(
|
||||
key.clone(),
|
||||
WebhookInstallation {
|
||||
group: "sync-1".to_string(),
|
||||
endpoint,
|
||||
repo: "repo".to_string(),
|
||||
url: "https://mirror.example.test/webhook".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
save_webhook_state(temp.path(), &state).unwrap();
|
||||
let loaded = load_webhook_state(temp.path()).unwrap();
|
||||
|
||||
assert_eq!(loaded.installations[&key].repo, "repo");
|
||||
assert_eq!(
|
||||
loaded.installations[&key].url,
|
||||
"https://mirror.example.test/webhook"
|
||||
);
|
||||
}
|
||||
|
||||
fn site(name: &str, provider: ProviderKind) -> SiteConfig {
|
||||
SiteConfig {
|
||||
name: name.to_string(),
|
||||
provider,
|
||||
base_url: "https://example.test".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("secret".to_string()),
|
||||
git_username: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint(site: &str, kind: NamespaceKind, namespace: &str) -> EndpointConfig {
|
||||
EndpointConfig {
|
||||
site: site.to_string(),
|
||||
kind,
|
||||
namespace: namespace.to_string(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user