[O] Better interactive config

This commit is contained in:
2026-05-08 00:52:37 +00:00
parent f0153ea396
commit 0bc4abf1a7
4 changed files with 577 additions and 56 deletions
+174 -8
View File
@@ -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")
);
}
+152 -23
View File
@@ -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")