[O] Better interactive config
This commit is contained in:
@@ -43,7 +43,7 @@ Example wizard flow:
|
||||
PAT quick setup:
|
||||
|
||||
- GitHub: open `https://github.com/settings/tokens`, create a classic PAT with `repo` permissions, then copy the token.
|
||||
- GitLab: open `<base-url>/-/user_settings/personal_access_tokens?name=git-sync&scopes=api`, create the token, then copy it.
|
||||
- GitLab: open `<base-url>/-/user_settings/personal_access_tokens?name=git-sync&scopes=api,write_repository`, select `api` and `write_repository`, create the token, then copy it.
|
||||
- Gitea: open `<base-url>/user/settings/applications`, create a token with repository access, then copy it.
|
||||
- Forgejo: open `<base-url>/user/settings/applications`, create a token with repository access, then copy it.
|
||||
|
||||
@@ -109,7 +109,7 @@ Use cron or another scheduler for automatic execution:
|
||||
|
||||
Webhook mode reduces the window for divergent commits by syncing a repository immediately after a provider sends a push event. It is still conservative: if two endpoints receive independent commits before webhook sync catches up, the normal divergence rules still apply.
|
||||
|
||||
The interactive wizard can configure webhooks for you. It asks for the public URL, checks that the URL is reachable from the current machine, creates a webhook secret, and can enable periodic full syncs while `git-sync serve` is running.
|
||||
The interactive wizard can configure webhooks for you. During setup it starts a temporary test listener on `127.0.0.1:8787`, asks for the public URL, checks that the URL is reachable from the current machine, creates a webhook secret, and can enable periodic full syncs while `git-sync serve` is running.
|
||||
|
||||
Example config:
|
||||
|
||||
|
||||
+249
-23
@@ -2,12 +2,15 @@ use std::fmt::Display;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use console::{Term, style};
|
||||
use dialoguer::{Confirm, Input, Password, Select, theme::ColorfulTheme};
|
||||
use reqwest::blocking::Client;
|
||||
use tiny_http::{Request, Response, Server, StatusCode};
|
||||
use url::Url;
|
||||
|
||||
use crate::config::{
|
||||
@@ -17,6 +20,8 @@ use crate::config::{
|
||||
use crate::provider::ProviderClient;
|
||||
use crate::webhook::check_webhook_url_reachable;
|
||||
|
||||
const DEFAULT_WEBHOOK_LISTEN: &str = "127.0.0.1:8787";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ProfileTarget {
|
||||
base_url: String,
|
||||
@@ -40,7 +45,7 @@ pub fn run_config_wizard(path: &Path) -> Result<()> {
|
||||
println!();
|
||||
println!("{}", style("git-sync configuration wizard").cyan().bold());
|
||||
let description = if existing_config {
|
||||
"Review, add, or delete sync groups."
|
||||
"Review, add, edit, or delete sync groups."
|
||||
} else {
|
||||
"Enter profile or organization URLs, then git-sync will build the mirror group."
|
||||
};
|
||||
@@ -60,6 +65,11 @@ pub fn run_config_wizard(path: &Path) -> Result<()> {
|
||||
add_sync_group_styled(&mut config, &theme)?;
|
||||
print_sync_groups(&config);
|
||||
}
|
||||
WizardAction::EditSyncGroup => {
|
||||
if edit_sync_group_styled(&mut config, &theme)? {
|
||||
print_sync_groups(&config);
|
||||
}
|
||||
}
|
||||
WizardAction::DeleteSyncGroup => {
|
||||
if delete_sync_group_styled(&mut config, &theme)? {
|
||||
print_sync_groups(&config);
|
||||
@@ -81,19 +91,61 @@ pub fn run_config_wizard(path: &Path) -> Result<()> {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum WizardAction {
|
||||
AddSyncGroup,
|
||||
EditSyncGroup,
|
||||
DeleteSyncGroup,
|
||||
Done,
|
||||
}
|
||||
|
||||
fn add_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<()> {
|
||||
let endpoints = prompt_sync_group_endpoints_styled(config, theme, &[])?;
|
||||
config.upsert_mirror(MirrorConfig {
|
||||
name: next_mirror_name(config),
|
||||
endpoints,
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
});
|
||||
prompt_webhook_setup_styled(config, theme)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_sync_group_endpoints_styled(
|
||||
config: &mut Config,
|
||||
theme: &ColorfulTheme,
|
||||
existing: &[EndpointConfig],
|
||||
) -> Result<Vec<EndpointConfig>> {
|
||||
let mut endpoints = Vec::new();
|
||||
|
||||
let first = prompt_target_styled(theme, "Profile/org URL")?;
|
||||
let first = prompt_target_styled(
|
||||
theme,
|
||||
"Profile/org URL",
|
||||
endpoint_profile_url(config, existing.first()),
|
||||
)?;
|
||||
endpoints.push(ensure_credentials_styled(config, first, theme)?);
|
||||
|
||||
let second = prompt_target_styled(theme, "Profile/org URL to sync with")?;
|
||||
let second = prompt_target_styled(
|
||||
theme,
|
||||
"Profile/org URL to sync with",
|
||||
endpoint_profile_url(config, existing.get(1)),
|
||||
)?;
|
||||
endpoints.push(ensure_credentials_styled(config, second, theme)?);
|
||||
|
||||
for (index, endpoint) in existing.iter().enumerate().skip(2) {
|
||||
let Some(default_url) = endpoint_profile_url(config, Some(endpoint)) else {
|
||||
continue;
|
||||
};
|
||||
if !Confirm::with_theme(theme)
|
||||
.with_prompt(format!("Keep endpoint {}?", index + 1))
|
||||
.default(true)
|
||||
.interact()?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let next = prompt_target_styled(theme, "Additional profile/org URL", Some(default_url))?;
|
||||
endpoints.push(ensure_credentials_styled(config, next, theme)?);
|
||||
}
|
||||
|
||||
loop {
|
||||
let prompt = if endpoints.len() == 2 {
|
||||
"Add a third endpoint for 3-way sync?"
|
||||
@@ -107,20 +159,11 @@ fn add_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<(
|
||||
{
|
||||
break;
|
||||
}
|
||||
let next = prompt_target_styled(theme, "Additional profile/org URL")?;
|
||||
let next = prompt_target_styled(theme, "Additional profile/org URL", None)?;
|
||||
endpoints.push(ensure_credentials_styled(config, next, theme)?);
|
||||
}
|
||||
|
||||
config.upsert_mirror(MirrorConfig {
|
||||
name: next_mirror_name(config),
|
||||
endpoints,
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
});
|
||||
prompt_webhook_setup_styled(config, theme)?;
|
||||
|
||||
Ok(())
|
||||
Ok(endpoints)
|
||||
}
|
||||
|
||||
fn prompt_webhook_setup_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<()> {
|
||||
@@ -152,6 +195,29 @@ fn prompt_webhook_setup_styled(config: &mut Config, theme: &ColorfulTheme) -> Re
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
print_webhook_url_instructions();
|
||||
let demo_server = match DemoWebhookServer::start(DEFAULT_WEBHOOK_LISTEN) {
|
||||
Ok(server) => {
|
||||
println!(
|
||||
" {} Temporary test listener running on {}",
|
||||
style("-").cyan(),
|
||||
style(server.listen()).bold()
|
||||
);
|
||||
Some(server)
|
||||
}
|
||||
Err(error) => {
|
||||
println!(
|
||||
" {} Could not start temporary test listener on {}: {error:#}",
|
||||
style("-").yellow(),
|
||||
style(DEFAULT_WEBHOOK_LISTEN).bold()
|
||||
);
|
||||
println!(
|
||||
" {} If git-sync serve is already running there, you can continue.",
|
||||
style("-").yellow()
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let url = Input::<String>::with_theme(theme)
|
||||
.with_prompt("Webhook URL reachable by GitHub/GitLab/Gitea")
|
||||
.validate_with(|value: &String| validate_url(value))
|
||||
@@ -177,6 +243,7 @@ fn prompt_webhook_setup_styled(config: &mut Config, theme: &ColorfulTheme) -> Re
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(demo_server);
|
||||
let full_sync_interval_minutes = if Confirm::with_theme(theme)
|
||||
.with_prompt("Run periodic full sync while the webhook server is running?")
|
||||
.default(true)
|
||||
@@ -201,8 +268,111 @@ fn prompt_webhook_setup_styled(config: &mut Config, theme: &ColorfulTheme) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_webhook_url_instructions() {
|
||||
println!();
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Webhook URL").cyan().bold(),
|
||||
style("must be reachable from every Git provider").dim()
|
||||
);
|
||||
println!(
|
||||
" {} Start the receiver with: {}",
|
||||
style("-").cyan(),
|
||||
style(format!("git-sync serve --listen {DEFAULT_WEBHOOK_LISTEN}")).bold()
|
||||
);
|
||||
println!(
|
||||
" {} The receiver accepts: {} and {}",
|
||||
style("-").cyan(),
|
||||
style("POST /").bold(),
|
||||
style("POST /webhook").bold()
|
||||
);
|
||||
println!(
|
||||
" {} If running locally, expose it with a tunnel, for example: {}",
|
||||
style("-").cyan(),
|
||||
style(format!(
|
||||
"cloudflared tunnel --url http://{DEFAULT_WEBHOOK_LISTEN}"
|
||||
))
|
||||
.bold()
|
||||
);
|
||||
println!(
|
||||
" {} Then enter the public URL, usually ending in {}",
|
||||
style("-").cyan(),
|
||||
style("/webhook").bold()
|
||||
);
|
||||
println!(
|
||||
" {} The wizard starts a temporary listener on {} so you can test the tunnel now.",
|
||||
style("-").cyan(),
|
||||
style(DEFAULT_WEBHOOK_LISTEN).bold()
|
||||
);
|
||||
}
|
||||
|
||||
struct DemoWebhookServer {
|
||||
listen: String,
|
||||
stop: mpsc::Sender<()>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl DemoWebhookServer {
|
||||
fn start(listen: &str) -> Result<Self> {
|
||||
let server = Server::http(listen)
|
||||
.map_err(|error| anyhow!("failed to listen on {listen}: {error}"))?;
|
||||
let listen = server.server_addr().to_string();
|
||||
let (stop, stop_receiver) = mpsc::channel();
|
||||
let handle = thread::spawn(move || run_demo_webhook_server(server, stop_receiver));
|
||||
Ok(Self {
|
||||
listen,
|
||||
stop,
|
||||
handle: Some(handle),
|
||||
})
|
||||
}
|
||||
|
||||
fn listen(&self) -> &str {
|
||||
&self.listen
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DemoWebhookServer {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stop.send(());
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_demo_webhook_server(server: Server, stop: mpsc::Receiver<()>) {
|
||||
loop {
|
||||
if stop.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
match server.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(Some(request)) => respond_demo_webhook_request(request),
|
||||
Ok(None) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn respond_demo_webhook_request(request: Request) {
|
||||
let path = request.url().split('?').next().unwrap_or(request.url());
|
||||
let (status, body) = if path == "/" || path == "/webhook" {
|
||||
(
|
||||
StatusCode(200),
|
||||
"git-sync webhook setup listener\nThis temporary server only confirms that your public URL reaches this machine.\nAfter saving config, run git-sync serve for real webhooks.\n",
|
||||
)
|
||||
} else {
|
||||
(StatusCode(404), "not found\n")
|
||||
};
|
||||
let _ = request.respond(Response::from_string(body).with_status_code(status));
|
||||
}
|
||||
|
||||
fn prompt_wizard_action_styled(theme: &ColorfulTheme) -> Result<WizardAction> {
|
||||
let options = ["Add another sync group", "Delete an existing group", "Done"];
|
||||
let options = [
|
||||
"Add another sync group",
|
||||
"Edit an existing group",
|
||||
"Delete an existing group",
|
||||
"Done",
|
||||
];
|
||||
let index = Select::with_theme(theme)
|
||||
.with_prompt("What would you like to do?")
|
||||
.items(options)
|
||||
@@ -211,11 +381,46 @@ fn prompt_wizard_action_styled(theme: &ColorfulTheme) -> Result<WizardAction> {
|
||||
|
||||
Ok(match index {
|
||||
0 => WizardAction::AddSyncGroup,
|
||||
1 => WizardAction::DeleteSyncGroup,
|
||||
1 => WizardAction::EditSyncGroup,
|
||||
2 => WizardAction::DeleteSyncGroup,
|
||||
_ => WizardAction::Done,
|
||||
})
|
||||
}
|
||||
|
||||
fn edit_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<bool> {
|
||||
if config.mirrors.is_empty() {
|
||||
println!("{}", style("No sync groups to edit.").yellow());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut options = numbered_sync_group_options(config);
|
||||
options.push("Back".to_string());
|
||||
let index = Select::with_theme(theme)
|
||||
.with_prompt("Edit sync group")
|
||||
.items(&options)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
if index == config.mirrors.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Editing").cyan().bold(),
|
||||
style(format!("sync group {}", index + 1)).cyan()
|
||||
);
|
||||
let existing = config.mirrors[index].endpoints.clone();
|
||||
let endpoints = prompt_sync_group_endpoints_styled(config, theme, &existing)?;
|
||||
config.mirrors[index].endpoints = endpoints;
|
||||
prompt_webhook_setup_styled(config, theme)?;
|
||||
println!(
|
||||
"{} {}",
|
||||
style("updated").green().bold(),
|
||||
style(format!("sync group {}", index + 1)).cyan()
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn delete_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<bool> {
|
||||
if config.mirrors.is_empty() {
|
||||
println!("{}", style("No sync groups to delete.").yellow());
|
||||
@@ -242,9 +447,18 @@ fn delete_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Resul
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn prompt_target_styled(theme: &ColorfulTheme, prompt: &str) -> Result<ProfileTarget> {
|
||||
let url = Input::<String>::with_theme(theme)
|
||||
.with_prompt(prompt)
|
||||
fn prompt_target_styled(
|
||||
theme: &ColorfulTheme,
|
||||
prompt: &str,
|
||||
default: Option<String>,
|
||||
) -> Result<ProfileTarget> {
|
||||
let input = Input::<String>::with_theme(theme).with_prompt(prompt);
|
||||
let input = if let Some(default) = default {
|
||||
input.default(default)
|
||||
} else {
|
||||
input
|
||||
};
|
||||
let url = input
|
||||
.validate_with(|value: &String| validate_required(value))
|
||||
.interact_text()?;
|
||||
let parsed = parse_profile_url(&url)?;
|
||||
@@ -546,9 +760,9 @@ fn pat_instruction_lines(provider: &ProviderKind, base_url: &str) -> Vec<String>
|
||||
.to_string(),
|
||||
],
|
||||
ProviderKind::Gitlab => vec![
|
||||
"Create a personal access token with API permissions.".to_string(),
|
||||
"Create a personal access token with API and repository write permissions.".to_string(),
|
||||
format!("Open: {url}"),
|
||||
"Select api, create the token, then paste it here.".to_string(),
|
||||
"Select api and write_repository, create the token, then paste it here.".to_string(),
|
||||
],
|
||||
ProviderKind::Gitea => vec![
|
||||
"Create a personal access token with repository permissions.".to_string(),
|
||||
@@ -755,6 +969,16 @@ fn endpoint_url(site: &SiteConfig, endpoint: &EndpointConfig) -> String {
|
||||
format!("{}/{}", trim_url_scheme(&site.base_url), endpoint.namespace)
|
||||
}
|
||||
|
||||
fn endpoint_profile_url(config: &Config, endpoint: Option<&EndpointConfig>) -> Option<String> {
|
||||
let endpoint = endpoint?;
|
||||
let site = config.site(&endpoint.site)?;
|
||||
Some(format!(
|
||||
"{}/{}",
|
||||
trim_url_end(&site.base_url),
|
||||
endpoint.namespace
|
||||
))
|
||||
}
|
||||
|
||||
fn trim_url_scheme(value: &str) -> String {
|
||||
value
|
||||
.trim_start_matches("https://")
|
||||
@@ -852,7 +1076,9 @@ fn token_creation_url(provider: &ProviderKind, base_url: &str) -> String {
|
||||
match provider {
|
||||
ProviderKind::Github => format!("{base}/settings/tokens"),
|
||||
ProviderKind::Gitlab => {
|
||||
format!("{base}/-/user_settings/personal_access_tokens?name=git-sync&scopes=api")
|
||||
format!(
|
||||
"{base}/-/user_settings/personal_access_tokens?name=git-sync&scopes=api,write_repository"
|
||||
)
|
||||
}
|
||||
ProviderKind::Gitea => format!("{base}/user/settings/applications"),
|
||||
ProviderKind::Forgejo => format!("{base}/user/settings/applications"),
|
||||
|
||||
+174
-8
@@ -12,7 +12,7 @@ fn wizard_builds_sync_group_from_profile_urls() {
|
||||
"",
|
||||
"n",
|
||||
"n",
|
||||
"3",
|
||||
"4",
|
||||
]
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
@@ -48,6 +48,7 @@ fn wizard_builds_sync_group_from_profile_urls() {
|
||||
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("Edit an existing group"));
|
||||
assert!(output.contains("Delete an existing group"));
|
||||
assert!(output.contains("Done"));
|
||||
}
|
||||
@@ -67,7 +68,7 @@ fn wizard_can_build_three_way_sync() {
|
||||
"",
|
||||
"n",
|
||||
"n",
|
||||
"3",
|
||||
"4",
|
||||
]
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
@@ -95,7 +96,7 @@ fn wizard_can_enable_webhooks() {
|
||||
"https://mirror.example.test/webhook",
|
||||
"y",
|
||||
"30",
|
||||
"3",
|
||||
"4",
|
||||
]
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
@@ -113,6 +114,12 @@ fn wizard_can_enable_webhooks() {
|
||||
webhook.secret,
|
||||
TokenConfig::Value("test-webhook-secret".to_string())
|
||||
);
|
||||
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("git-sync serve --listen 127.0.0.1:8787"));
|
||||
assert!(output.contains("cloudflared tunnel --url http://127.0.0.1:8787"));
|
||||
assert!(output.contains("POST / and POST /webhook"));
|
||||
assert!(output.contains("temporary listener on 127.0.0.1:8787"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -136,7 +143,7 @@ fn wizard_reuses_existing_credentials_for_same_instance() {
|
||||
"",
|
||||
"n",
|
||||
"n",
|
||||
"3",
|
||||
"4",
|
||||
]
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
@@ -191,7 +198,7 @@ fn wizard_starts_existing_config_at_sync_group_menu() {
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
let mut reader = Cursor::new(b"3\n".as_slice());
|
||||
let mut reader = Cursor::new(b"4\n".as_slice());
|
||||
let mut output = Vec::new();
|
||||
|
||||
let updated = run_config_wizard_with_io(config, &mut reader, &mut output).unwrap();
|
||||
@@ -203,6 +210,151 @@ fn wizard_starts_existing_config_at_sync_group_menu() {
|
||||
assert!(!output.contains("Profile/org URL:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_edits_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,
|
||||
},
|
||||
SiteConfig {
|
||||
name: "gitlab".to_string(),
|
||||
provider: ProviderKind::Gitlab,
|
||||
base_url: "https://gitlab.com".to_string(),
|
||||
api_url: None,
|
||||
token: TokenConfig::Value("existing-gl".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: false,
|
||||
visibility: Visibility::Public,
|
||||
allow_force: true,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
let input = [
|
||||
"2",
|
||||
"1",
|
||||
"https://github.com/bob",
|
||||
"",
|
||||
"https://gitlab.com/bob",
|
||||
"",
|
||||
"n",
|
||||
"n",
|
||||
"4",
|
||||
]
|
||||
.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 mirror = &updated.mirrors[0];
|
||||
assert_eq!(mirror.name, "sync-1");
|
||||
assert_eq!(mirror.endpoints.len(), 2);
|
||||
assert_eq!(mirror.endpoints[0].site, "github");
|
||||
assert_eq!(mirror.endpoints[0].namespace, "bob");
|
||||
assert_eq!(mirror.endpoints[1].site, "gitlab");
|
||||
assert_eq!(mirror.endpoints[1].namespace, "bob");
|
||||
assert!(!mirror.create_missing);
|
||||
assert_eq!(mirror.visibility, Visibility::Public);
|
||||
assert!(mirror.allow_force);
|
||||
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("Edit sync group"));
|
||||
assert!(output.contains("updated sync group 1"));
|
||||
assert!(output.contains("github.com/bob <-> gitlab.com/bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_prefills_existing_sync_group_when_editing() {
|
||||
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", "", "", "", "", "n", "n", "4"].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();
|
||||
|
||||
let mirror = &updated.mirrors[0];
|
||||
assert_eq!(mirror.endpoints.len(), 2);
|
||||
assert_eq!(mirror.endpoints[0].site, "github");
|
||||
assert_eq!(mirror.endpoints[0].namespace, "alice");
|
||||
assert_eq!(mirror.endpoints[1].site, "gitea");
|
||||
assert_eq!(mirror.endpoints[1].namespace, "alice");
|
||||
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("Profile/org URL [https://github.com/alice]:"));
|
||||
assert!(output.contains("Profile/org URL to sync with [https://gitea.example.test/alice]:"));
|
||||
assert!(output.contains("updated sync group 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_deletes_existing_sync_group_from_menu() {
|
||||
let config = Config {
|
||||
@@ -244,7 +396,7 @@ fn wizard_deletes_existing_sync_group_from_menu() {
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
let input = ["2", "1", "3"].join("\n") + "\n";
|
||||
let input = ["3", "1", "4"].join("\n") + "\n";
|
||||
let mut reader = Cursor::new(input.as_bytes());
|
||||
let mut output = Vec::new();
|
||||
|
||||
@@ -299,7 +451,7 @@ fn wizard_can_go_back_from_delete_menu() {
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
let input = ["2", "2", "3"].join("\n") + "\n";
|
||||
let input = ["3", "2", "4"].join("\n") + "\n";
|
||||
let mut reader = Cursor::new(input.as_bytes());
|
||||
let mut output = Vec::new();
|
||||
|
||||
@@ -373,7 +525,7 @@ fn token_creation_urls_are_provider_specific() {
|
||||
);
|
||||
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"
|
||||
"https://gitlab.example.test/-/user_settings/personal_access_tokens?name=git-sync&scopes=api,write_repository"
|
||||
);
|
||||
assert_eq!(
|
||||
token_creation_url(&ProviderKind::Gitea, "gitea.example.test"),
|
||||
@@ -384,3 +536,17 @@ fn token_creation_urls_are_provider_specific() {
|
||||
"https://forgejo.example.test/user/settings/applications"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_webhook_server_answers_reachability_checks() {
|
||||
let server = DemoWebhookServer::start("127.0.0.1:0").unwrap();
|
||||
let response = reqwest::blocking::get(format!("http://{}/webhook", server.listen())).unwrap();
|
||||
|
||||
assert!(response.status().is_success());
|
||||
assert!(
|
||||
response
|
||||
.text()
|
||||
.unwrap()
|
||||
.contains("git-sync webhook setup listener")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ where
|
||||
add_sync_group(reader, writer, &mut config)?;
|
||||
write_sync_groups(&config, writer)?;
|
||||
}
|
||||
WizardAction::EditSyncGroup => {
|
||||
if edit_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)?;
|
||||
@@ -41,22 +46,7 @@ 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)?);
|
||||
}
|
||||
|
||||
let endpoints = prompt_sync_group_endpoints(reader, writer, config, &[])?;
|
||||
config.upsert_mirror(MirrorConfig {
|
||||
name: next_mirror_name(config),
|
||||
endpoints,
|
||||
@@ -68,6 +58,69 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_sync_group_endpoints<R, W>(
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
config: &mut Config,
|
||||
existing: &[EndpointConfig],
|
||||
) -> Result<Vec<EndpointConfig>>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
let mut endpoints = Vec::new();
|
||||
let first = prompt_target(
|
||||
reader,
|
||||
writer,
|
||||
"Profile/org URL",
|
||||
endpoint_profile_url(config, existing.first()),
|
||||
)?;
|
||||
endpoints.push(ensure_credentials(config, first, reader, writer)?);
|
||||
let second = prompt_target(
|
||||
reader,
|
||||
writer,
|
||||
"Profile/org URL to sync with",
|
||||
endpoint_profile_url(config, existing.get(1)),
|
||||
)?;
|
||||
endpoints.push(ensure_credentials(config, second, reader, writer)?);
|
||||
|
||||
for (index, endpoint) in existing.iter().enumerate().skip(2) {
|
||||
let Some(default_url) = endpoint_profile_url(config, Some(endpoint)) else {
|
||||
continue;
|
||||
};
|
||||
if !prompt_bool(
|
||||
reader,
|
||||
writer,
|
||||
&format!("Keep endpoint {}?", index + 1),
|
||||
true,
|
||||
)? {
|
||||
continue;
|
||||
}
|
||||
let next = prompt_target(
|
||||
reader,
|
||||
writer,
|
||||
"Additional profile/org URL",
|
||||
Some(default_url),
|
||||
)?;
|
||||
endpoints.push(ensure_credentials(config, next, reader, writer)?);
|
||||
}
|
||||
|
||||
loop {
|
||||
let prompt = if endpoints.len() == 2 {
|
||||
"Add a third endpoint for 3-way sync?"
|
||||
} else {
|
||||
"Add another endpoint to this sync group?"
|
||||
};
|
||||
if !prompt_bool(reader, writer, prompt, false)? {
|
||||
break;
|
||||
}
|
||||
let next = prompt_target(reader, writer, "Additional profile/org URL", None)?;
|
||||
endpoints.push(ensure_credentials(config, next, reader, writer)?);
|
||||
}
|
||||
|
||||
Ok(endpoints)
|
||||
}
|
||||
|
||||
fn prompt_webhook_setup<R, W>(reader: &mut R, writer: &mut W, config: &mut Config) -> Result<()>
|
||||
where
|
||||
R: BufRead,
|
||||
@@ -88,6 +141,7 @@ where
|
||||
if !prompt_bool(reader, writer, "Install webhook?", true)? {
|
||||
return Ok(());
|
||||
}
|
||||
write_webhook_url_instructions(writer)?;
|
||||
let url = prompt_required(reader, writer, "Webhook URL reachable by providers")?;
|
||||
if let Err(error) = validate_url(&url) {
|
||||
bail!(error);
|
||||
@@ -116,6 +170,34 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_webhook_url_instructions<W>(writer: &mut W) -> Result<()>
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
writeln!(
|
||||
writer,
|
||||
"Webhook URL must be reachable from every Git provider."
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"Start the receiver with: git-sync serve --listen 127.0.0.1:8787"
|
||||
)?;
|
||||
writeln!(writer, "The receiver accepts: POST / and POST /webhook")?;
|
||||
writeln!(
|
||||
writer,
|
||||
"If running locally, expose it with a tunnel, for example: cloudflared tunnel --url http://127.0.0.1:8787"
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"Then enter the public URL, usually ending in /webhook."
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"During the real wizard, git-sync starts a temporary listener on 127.0.0.1:8787 so you can test the tunnel now."
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_wizard_action<R, W>(reader: &mut R, writer: &mut W) -> Result<WizardAction>
|
||||
where
|
||||
R: BufRead,
|
||||
@@ -124,18 +206,57 @@ where
|
||||
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")?;
|
||||
writeln!(writer, " 2. Edit an existing group")?;
|
||||
writeln!(writer, " 3. Delete an existing group")?;
|
||||
writeln!(writer, " 4. 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" => {
|
||||
"2" | "edit" | "edit an existing group" => return Ok(WizardAction::EditSyncGroup),
|
||||
"3" | "delete" | "delete an existing group" => {
|
||||
return Ok(WizardAction::DeleteSyncGroup);
|
||||
}
|
||||
"3" | "done" | "finish" => return Ok(WizardAction::Done),
|
||||
_ => writeln!(writer, "Enter 1, 2, or 3.")?,
|
||||
"4" | "done" | "finish" => return Ok(WizardAction::Done),
|
||||
_ => writeln!(writer, "Enter 1, 2, 3, or 4.")?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_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 edit.")?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
loop {
|
||||
writeln!(writer, "Edit 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 existing = config.mirrors[index - 1].endpoints.clone();
|
||||
let endpoints = prompt_sync_group_endpoints(reader, writer, config, &existing)?;
|
||||
config.mirrors[index - 1].endpoints = endpoints;
|
||||
prompt_webhook_setup(reader, writer, config)?;
|
||||
writeln!(writer, "updated 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.")?,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,12 +296,20 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_target<R, W>(reader: &mut R, writer: &mut W, prompt: &str) -> Result<ProfileTarget>
|
||||
fn prompt_target<R, W>(
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
prompt: &str,
|
||||
default: Option<String>,
|
||||
) -> Result<ProfileTarget>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
let url = prompt_required(reader, writer, prompt)?;
|
||||
let url = match default {
|
||||
Some(default) => prompt_with_default(reader, writer, prompt, &default)?,
|
||||
None => 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")
|
||||
|
||||
Reference in New Issue
Block a user