diff --git a/README.md b/README.md index 219ce2b..fb77d6a 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ Supported providers: - GitHub - GitLab - Gitea +- Forgejo The program uses provider APIs to list and create repositories, then uses the local `git` CLI to fetch and push branches and tags. +Forgejo uses the same API shape as Gitea. ## Install @@ -48,6 +50,7 @@ PAT quick setup: - GitHub: open `https://github.com/settings/tokens`, create a classic PAT with `repo` permissions, then copy the token. - GitLab: open `/-/user_settings/personal_access_tokens?name=git-sync&scopes=api`, create the token, then copy it. - Gitea: open `/user/settings/applications`, create a token with repository access, then copy it. +- Forgejo: open `/user/settings/applications`, create a token with repository access, then copy it. Add sites. Prefer `--token-env` so PATs do not live in shell history or the config file. @@ -71,6 +74,7 @@ For self-hosted providers, `--base-url` is the web root. API URLs default to: - GitHub Enterprise: `/api/v3` - GitLab: `/api/v4` - Gitea: `/api/v1` +- Forgejo: `/api/v1` Override with `--api-url` if your instance is different. diff --git a/src/config.rs b/src/config.rs index ac54ef1..0ab30e4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,6 +33,7 @@ pub enum ProviderKind { Github, Gitlab, Gitea, + Forgejo, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -190,6 +191,7 @@ impl SiteConfig { } ProviderKind::Gitlab => format!("{}/api/v4", trim_end(&self.base_url)), ProviderKind::Gitea => format!("{}/api/v1", trim_end(&self.base_url)), + ProviderKind::Forgejo => format!("{}/api/v1", trim_end(&self.base_url)), } } } @@ -400,6 +402,14 @@ mod tests { .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 { diff --git a/src/interactive.rs b/src/interactive.rs index 530236f..67283f6 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -369,7 +369,7 @@ fn matching_sites<'a>(config: &'a Config, target: &ProfileTarget) -> Vec<&'a Sit } fn prompt_provider_styled(theme: &ColorfulTheme, base_url: &str) -> Result { - let options = ["GitHub", "GitLab", "Gitea"]; + let options = ["GitHub", "GitLab", "Gitea", "Forgejo"]; let index = Select::with_theme(theme) .with_prompt(format!("Provider for {base_url}")) .items(options) @@ -378,7 +378,8 @@ fn prompt_provider_styled(theme: &ColorfulTheme, base_url: &str) -> Result ProviderKind::Github, 1 => ProviderKind::Gitlab, - _ => ProviderKind::Gitea, + 2 => ProviderKind::Gitea, + _ => ProviderKind::Forgejo, }) } @@ -477,6 +478,11 @@ fn pat_instruction_lines(provider: &ProviderKind, base_url: &str) -> Vec format!("Open: {url}"), "Generate a new token, allow repository access, then paste it here.".to_string(), ], + ProviderKind::Forgejo => vec![ + "Create a personal access token with repository permissions.".to_string(), + format!("Open: {url}"), + "Generate a new token, allow repository access, then paste it here.".to_string(), + ], } } @@ -686,7 +692,11 @@ where "github" => return Ok(ProviderKind::Github), "gitlab" => return Ok(ProviderKind::Gitlab), "gitea" => return Ok(ProviderKind::Gitea), - _ => writeln!(writer, "Provider must be github, gitlab, or gitea.")?, + "forgejo" => return Ok(ProviderKind::Forgejo), + _ => writeln!( + writer, + "Provider must be github, gitlab, gitea, or forgejo." + )?, } } } @@ -842,6 +852,8 @@ fn known_provider_from_host(host: &str) -> Option { Some(ProviderKind::Github) } else if host == "gitlab.com" || host.ends_with(".gitlab.com") || host.contains("gitlab") { Some(ProviderKind::Gitlab) + } else if host == "codeberg.org" || host.contains("forgejo") { + Some(ProviderKind::Forgejo) } else if host.contains("gitea") { Some(ProviderKind::Gitea) } else { @@ -855,6 +867,14 @@ fn detect_provider_from_instance(base_url: &str) -> Option { .build() .ok()?; let base = trim_url_end(base_url); + if client + .get(format!("{base}/api/forgejo/v1/version")) + .send() + .ok() + .is_some_and(|response| response.status().is_success()) + { + return Some(ProviderKind::Forgejo); + } if client .get(format!("{base}/api/v1/version")) .send() @@ -937,7 +957,7 @@ fn detect_namespace_kind_public( .is_some_and(|items| !items.is_empty()) .then_some(NamespaceKind::User) } - ProviderKind::Gitea => { + ProviderKind::Gitea | ProviderKind::Forgejo => { if client .get(format!("{api_base}/orgs/{namespace}")) .send() @@ -1003,6 +1023,7 @@ fn default_base_url(provider: &ProviderKind) -> &'static str { ProviderKind::Github => "https://github.com", ProviderKind::Gitlab => "https://gitlab.com", ProviderKind::Gitea => "https://gitea.example.com", + ProviderKind::Forgejo => "https://forgejo.example.com", } } @@ -1065,6 +1086,7 @@ fn provider_slug(provider: &ProviderKind) -> &'static str { ProviderKind::Github => "github", ProviderKind::Gitlab => "gitlab", ProviderKind::Gitea => "gitea", + ProviderKind::Forgejo => "forgejo", } } @@ -1078,6 +1100,7 @@ fn token_creation_url(provider: &ProviderKind, base_url: &str) -> String { format!("{base}/-/user_settings/personal_access_tokens?name=git-sync&scopes=api") } ProviderKind::Gitea => format!("{base}/user/settings/applications"), + ProviderKind::Forgejo => format!("{base}/user/settings/applications"), } } @@ -1449,5 +1472,9 @@ mod tests { 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" + ); } } diff --git a/src/main.rs b/src/main.rs index f27db43..4050efa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -121,6 +121,7 @@ enum ProviderArg { Github, Gitlab, Gitea, + Forgejo, } #[derive(Clone, Debug, ValueEnum)] @@ -303,6 +304,7 @@ impl From for ProviderKind { ProviderArg::Github => Self::Github, ProviderArg::Gitlab => Self::Gitlab, ProviderArg::Gitea => Self::Gitea, + ProviderArg::Forgejo => Self::Forgejo, } } } @@ -397,6 +399,32 @@ mod tests { assert_eq!(args.jobs, 8); } + #[test] + fn cli_accepts_new_provider_kinds() { + for (name, expected) in [("forgejo", ProviderKind::Forgejo)] { + let cli = Cli::try_parse_from([ + "git-sync", + "config", + "site", + "add", + "--name", + name, + "--provider", + name, + "--base-url", + "https://example.test", + "--token", + "token", + ]) + .unwrap(); + + let Command::Config(ConfigCommand::Site(SiteCommand::Add(args))) = cli.command else { + panic!("parsed unexpected command"); + }; + assert_eq!(ProviderKind::from(args.provider), expected); + } + } + #[test] fn endpoint_parser_supports_aliases_and_rejects_bad_kinds() { let endpoint = parse_endpoint("github:organization:MewoLab").unwrap(); diff --git a/src/provider.rs b/src/provider.rs index 8f419b2..17fc29f 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -44,6 +44,7 @@ impl<'a> ProviderClient<'a> { ProviderKind::Github => self.github_list_repos(endpoint), ProviderKind::Gitlab => self.gitlab_list_repos(endpoint), ProviderKind::Gitea => self.gitea_list_repos(endpoint), + ProviderKind::Forgejo => self.gitea_list_repos(endpoint), } } @@ -62,6 +63,9 @@ impl<'a> ProviderClient<'a> { self.gitlab_create_repo(endpoint, name, visibility, description) } ProviderKind::Gitea => self.gitea_create_repo(endpoint, name, visibility, description), + ProviderKind::Forgejo => { + self.gitea_create_repo(endpoint, name, visibility, description) + } } } @@ -75,6 +79,7 @@ impl<'a> ProviderClient<'a> { ProviderKind::Github => self.github_detect_namespace_kind(namespace), ProviderKind::Gitlab => self.gitlab_detect_namespace_kind(namespace), ProviderKind::Gitea => self.gitea_detect_namespace_kind(namespace), + ProviderKind::Forgejo => self.gitea_detect_namespace_kind(namespace), } } @@ -92,7 +97,9 @@ impl<'a> ProviderClient<'a> { .clone() .unwrap_or_else(|| match self.site.provider { ProviderKind::Github => "x-access-token".to_string(), - ProviderKind::Gitlab | ProviderKind::Gitea => "oauth2".to_string(), + ProviderKind::Gitlab | ProviderKind::Gitea | ProviderKind::Forgejo => { + "oauth2".to_string() + } }); url.set_username(&username) .map_err(|_| anyhow!("failed to set username on clone URL"))?; @@ -262,7 +269,7 @@ impl<'a> ProviderClient<'a> { let repos: Vec = self.paged_get(&url)?; Ok(repos.into_iter().map(Into::into).collect()) } - NamespaceKind::Group => bail!("Gitea endpoints use kind 'user' or 'org'"), + NamespaceKind::Group => bail!("Gitea/Forgejo endpoints use kind 'user' or 'org'"), } } @@ -278,7 +285,7 @@ impl<'a> ProviderClient<'a> { NamespaceKind::Org => { format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace) } - NamespaceKind::Group => bail!("Gitea endpoints use kind 'user' or 'org'"), + NamespaceKind::Group => bail!("Gitea/Forgejo endpoints use kind 'user' or 'org'"), }; let body = json!({ "name": name, @@ -377,7 +384,7 @@ impl<'a> ProviderClient<'a> { .context("PAT contains invalid header characters")?, ); } - ProviderKind::Gitea => { + ProviderKind::Gitea | ProviderKind::Forgejo => { headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("token {}", self.token)) @@ -538,6 +545,15 @@ mod tests { .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]