@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user