[+] Forgejo (#3)

* [+] Forgejo, tangled

* [-] Tangled
This commit is contained in:
2026-05-07 00:45:01 -04:00
committed by GitHub
parent 39ba96051c
commit 7b65d919d6
5 changed files with 93 additions and 8 deletions
+4
View File
@@ -7,8 +7,10 @@ Supported providers:
- GitHub - GitHub
- GitLab - GitLab
- Gitea - 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. 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 ## 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. - 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`, create the token, then copy it.
- Gitea: open `<base-url>/user/settings/applications`, create a token with repository access, 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.
Add sites. Prefer `--token-env` so PATs do not live in shell history or the config file. 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: `<base-url>/api/v3` - GitHub Enterprise: `<base-url>/api/v3`
- GitLab: `<base-url>/api/v4` - GitLab: `<base-url>/api/v4`
- Gitea: `<base-url>/api/v1` - Gitea: `<base-url>/api/v1`
- Forgejo: `<base-url>/api/v1`
Override with `--api-url` if your instance is different. Override with `--api-url` if your instance is different.
+10
View File
@@ -33,6 +33,7 @@ pub enum ProviderKind {
Github, Github,
Gitlab, Gitlab,
Gitea, Gitea,
Forgejo,
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -190,6 +191,7 @@ impl SiteConfig {
} }
ProviderKind::Gitlab => format!("{}/api/v4", trim_end(&self.base_url)), ProviderKind::Gitlab => format!("{}/api/v4", trim_end(&self.base_url)),
ProviderKind::Gitea => format!("{}/api/v1", 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(), .api_base(),
"https://gitea.example.test/api/v1" "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 { fn site(name: &str, provider: ProviderKind) -> SiteConfig {
+31 -4
View File
@@ -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<ProviderKind> { fn prompt_provider_styled(theme: &ColorfulTheme, base_url: &str) -> Result<ProviderKind> {
let options = ["GitHub", "GitLab", "Gitea"]; let options = ["GitHub", "GitLab", "Gitea", "Forgejo"];
let index = Select::with_theme(theme) let index = Select::with_theme(theme)
.with_prompt(format!("Provider for {base_url}")) .with_prompt(format!("Provider for {base_url}"))
.items(options) .items(options)
@@ -378,7 +378,8 @@ fn prompt_provider_styled(theme: &ColorfulTheme, base_url: &str) -> Result<Provi
Ok(match index { Ok(match index {
0 => ProviderKind::Github, 0 => ProviderKind::Github,
1 => ProviderKind::Gitlab, 1 => ProviderKind::Gitlab,
_ => ProviderKind::Gitea, 2 => ProviderKind::Gitea,
_ => ProviderKind::Forgejo,
}) })
} }
@@ -477,6 +478,11 @@ fn pat_instruction_lines(provider: &ProviderKind, base_url: &str) -> Vec<String>
format!("Open: {url}"), format!("Open: {url}"),
"Generate a new token, allow repository access, then paste it here.".to_string(), "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), "github" => return Ok(ProviderKind::Github),
"gitlab" => return Ok(ProviderKind::Gitlab), "gitlab" => return Ok(ProviderKind::Gitlab),
"gitea" => return Ok(ProviderKind::Gitea), "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<ProviderKind> {
Some(ProviderKind::Github) Some(ProviderKind::Github)
} else if host == "gitlab.com" || host.ends_with(".gitlab.com") || host.contains("gitlab") { } else if host == "gitlab.com" || host.ends_with(".gitlab.com") || host.contains("gitlab") {
Some(ProviderKind::Gitlab) Some(ProviderKind::Gitlab)
} else if host == "codeberg.org" || host.contains("forgejo") {
Some(ProviderKind::Forgejo)
} else if host.contains("gitea") { } else if host.contains("gitea") {
Some(ProviderKind::Gitea) Some(ProviderKind::Gitea)
} else { } else {
@@ -855,6 +867,14 @@ fn detect_provider_from_instance(base_url: &str) -> Option<ProviderKind> {
.build() .build()
.ok()?; .ok()?;
let base = trim_url_end(base_url); 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 if client
.get(format!("{base}/api/v1/version")) .get(format!("{base}/api/v1/version"))
.send() .send()
@@ -937,7 +957,7 @@ fn detect_namespace_kind_public(
.is_some_and(|items| !items.is_empty()) .is_some_and(|items| !items.is_empty())
.then_some(NamespaceKind::User) .then_some(NamespaceKind::User)
} }
ProviderKind::Gitea => { ProviderKind::Gitea | ProviderKind::Forgejo => {
if client if client
.get(format!("{api_base}/orgs/{namespace}")) .get(format!("{api_base}/orgs/{namespace}"))
.send() .send()
@@ -1003,6 +1023,7 @@ fn default_base_url(provider: &ProviderKind) -> &'static str {
ProviderKind::Github => "https://github.com", ProviderKind::Github => "https://github.com",
ProviderKind::Gitlab => "https://gitlab.com", ProviderKind::Gitlab => "https://gitlab.com",
ProviderKind::Gitea => "https://gitea.example.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::Github => "github",
ProviderKind::Gitlab => "gitlab", ProviderKind::Gitlab => "gitlab",
ProviderKind::Gitea => "gitea", 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") format!("{base}/-/user_settings/personal_access_tokens?name=git-sync&scopes=api")
} }
ProviderKind::Gitea => format!("{base}/user/settings/applications"), 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"), token_creation_url(&ProviderKind::Gitea, "gitea.example.test"),
"https://gitea.example.test/user/settings/applications" "https://gitea.example.test/user/settings/applications"
); );
assert_eq!(
token_creation_url(&ProviderKind::Forgejo, "forgejo.example.test"),
"https://forgejo.example.test/user/settings/applications"
);
} }
} }
+28
View File
@@ -121,6 +121,7 @@ enum ProviderArg {
Github, Github,
Gitlab, Gitlab,
Gitea, Gitea,
Forgejo,
} }
#[derive(Clone, Debug, ValueEnum)] #[derive(Clone, Debug, ValueEnum)]
@@ -303,6 +304,7 @@ impl From<ProviderArg> for ProviderKind {
ProviderArg::Github => Self::Github, ProviderArg::Github => Self::Github,
ProviderArg::Gitlab => Self::Gitlab, ProviderArg::Gitlab => Self::Gitlab,
ProviderArg::Gitea => Self::Gitea, ProviderArg::Gitea => Self::Gitea,
ProviderArg::Forgejo => Self::Forgejo,
} }
} }
} }
@@ -397,6 +399,32 @@ mod tests {
assert_eq!(args.jobs, 8); 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] #[test]
fn endpoint_parser_supports_aliases_and_rejects_bad_kinds() { fn endpoint_parser_supports_aliases_and_rejects_bad_kinds() {
let endpoint = parse_endpoint("github:organization:MewoLab").unwrap(); let endpoint = parse_endpoint("github:organization:MewoLab").unwrap();
+20 -4
View File
@@ -44,6 +44,7 @@ impl<'a> ProviderClient<'a> {
ProviderKind::Github => self.github_list_repos(endpoint), ProviderKind::Github => self.github_list_repos(endpoint),
ProviderKind::Gitlab => self.gitlab_list_repos(endpoint), ProviderKind::Gitlab => self.gitlab_list_repos(endpoint),
ProviderKind::Gitea => self.gitea_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) self.gitlab_create_repo(endpoint, name, visibility, description)
} }
ProviderKind::Gitea => self.gitea_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::Github => self.github_detect_namespace_kind(namespace),
ProviderKind::Gitlab => self.gitlab_detect_namespace_kind(namespace), ProviderKind::Gitlab => self.gitlab_detect_namespace_kind(namespace),
ProviderKind::Gitea => self.gitea_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() .clone()
.unwrap_or_else(|| match self.site.provider { .unwrap_or_else(|| match self.site.provider {
ProviderKind::Github => "x-access-token".to_string(), 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) url.set_username(&username)
.map_err(|_| anyhow!("failed to set username on clone URL"))?; .map_err(|_| anyhow!("failed to set username on clone URL"))?;
@@ -262,7 +269,7 @@ impl<'a> ProviderClient<'a> {
let repos: Vec<GiteaRepo> = self.paged_get(&url)?; let repos: Vec<GiteaRepo> = self.paged_get(&url)?;
Ok(repos.into_iter().map(Into::into).collect()) 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 => { NamespaceKind::Org => {
format!("{}/orgs/{}/repos", self.site.api_base(), endpoint.namespace) 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!({ let body = json!({
"name": name, "name": name,
@@ -377,7 +384,7 @@ impl<'a> ProviderClient<'a> {
.context("PAT contains invalid header characters")?, .context("PAT contains invalid header characters")?,
); );
} }
ProviderKind::Gitea => { ProviderKind::Gitea | ProviderKind::Forgejo => {
headers.insert( headers.insert(
AUTHORIZATION, AUTHORIZATION,
HeaderValue::from_str(&format!("token {}", self.token)) HeaderValue::from_str(&format!("token {}", self.token))
@@ -538,6 +545,15 @@ mod tests {
.unwrap(), .unwrap(),
"https://oauth2:secret@gitlab.example.test/alice/repo.git" "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] #[test]