summaryrefslogtreecommitdiffstats
path: root/src/repo.rs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-11-04 11:30:10 +0100
committerDaniel Baumann <daniel@debian.org>2025-01-23 21:00:02 +0100
commit816c704d1586954492cd9cffab04652d4e5a6c03 (patch)
tree2f97789629e5de8597ee7cb59d386590ac6c6f05 /src/repo.rs
parentInitial commit. (diff)
downloadforgejo-cli-816c704d1586954492cd9cffab04652d4e5a6c03.tar.xz
forgejo-cli-816c704d1586954492cd9cffab04652d4e5a6c03.zip
Adding upstream version 0.2.0.upstream
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'src/repo.rs')
-rw-r--r--src/repo.rs1093
1 files changed, 1093 insertions, 0 deletions
diff --git a/src/repo.rs b/src/repo.rs
new file mode 100644
index 0000000..adfde12
--- /dev/null
+++ b/src/repo.rs
@@ -0,0 +1,1093 @@
+use std::{io::Write, path::PathBuf, str::FromStr};
+
+use clap::Subcommand;
+use eyre::{eyre, Context, OptionExt, Result};
+use forgejo_api::{structs::CreateRepoOption, Forgejo};
+use url::Url;
+
+use crate::SpecialRender;
+
+pub struct RepoInfo {
+ url: Url,
+ name: Option<RepoName>,
+ remote_name: Option<String>,
+}
+
+impl RepoInfo {
+ pub fn get_current(
+ host: Option<&str>,
+ repo: Option<&RepoArg>,
+ remote: Option<&str>,
+ keys: &crate::keys::KeyInfo,
+ ) -> eyre::Result<Self> {
+ // l = domain/owner/name
+ // s = owner/name
+ // x = is present
+ // i = found locally by git
+ //
+ // | repo | host | remote | ans-host | ans-repo |
+ // |------|------|--------|----------|----------|
+ // | l | x | x | repo | repo |
+ // | l | x | i | repo | repo |
+ // | l | x | | repo | repo |
+ // | l | | x | repo | repo |
+ // | l | | i | repo | repo |
+ // | l | | | repo | repo |
+ // | s | x | x | host | repo |
+ // | s | x | i | host | repo |
+ // | s | x | | host | repo |
+ // | s | | x | remote | repo |
+ // | s | | i | remote | repo |
+ // | s | | | err | repo |
+ // | | x | x | remote | remote |
+ // | | x | i | host | ?remote |
+ // | | x | | host | none |
+ // | | | x | remote | remote |
+ // | | | i | remote | remote |
+ // | | | | err | remote |
+
+ let mut repo_url: Option<Url> = None;
+ let mut repo_name: Option<RepoName> = None;
+
+ if let Some(repo) = repo {
+ if let Some(host) = &repo.host {
+ repo_url = Url::parse(host)
+ .ok()
+ .filter(|x| !x.cannot_be_a_base())
+ .or_else(|| Url::parse(&format!("https://{host}/")).ok())
+ .map(|url| keys.deref_alias(url))
+ }
+ repo_name = Some(RepoName {
+ owner: repo.owner.clone(),
+ name: repo.name.clone(),
+ });
+ }
+
+ let repo_url = repo_url;
+ let repo_name = repo_name;
+
+ let host_url = host.and_then(|host| {
+ Url::parse(host)
+ .ok()
+ .filter(|x| !x.cannot_be_a_base())
+ .or_else(|| Url::parse(&format!("https://{host}/")).ok())
+ .map(|url| keys.deref_alias(url))
+ });
+
+ let mut final_remote_name = None;
+
+ let (remote_url, remote_repo_name) = {
+ let mut out = (None, None);
+ if let Ok(local_repo) = git2::Repository::discover(".") {
+ let mut name = remote.map(|s| s.to_owned());
+
+ // if there's only one remote, use that
+ if name.is_none() {
+ let all_remotes = local_repo.remotes()?;
+ if all_remotes.len() == 1 {
+ if let Some(remote_name) = all_remotes.get(0) {
+ name = Some(remote_name.to_owned());
+ }
+ }
+ }
+
+ // if the current branch is tracking a remote branch, use that remote
+ if name.is_none() {
+ let head = local_repo.head()?;
+ let branch_name = head.name().ok_or_eyre("branch name not UTF-8")?;
+
+ if let Ok(remote_name) = local_repo.branch_upstream_remote(branch_name) {
+ let remote_name_s =
+ remote_name.as_str().ok_or_eyre("remote name invalid")?;
+
+ if let Some(host_url) = &host_url {
+ let remote = local_repo.find_remote(remote_name_s)?;
+ let url_s = std::str::from_utf8(remote.url_bytes())?;
+ let url = keys.deref_alias(crate::ssh_url_parse(url_s)?);
+
+ if crate::host_with_port(&url) == crate::host_with_port(host_url) {
+ name = Some(remote_name_s.to_owned());
+ }
+ } else {
+ name = Some(remote_name_s.to_owned());
+ }
+ }
+ }
+
+ // if there's a remote whose host url matches the one
+ // specified with `--host`, use that
+ //
+ // This is different than using `--host` itself, since this
+ // will include the repo name, which `--host` can't do.
+ if name.is_none() {
+ if let Some(host_url) = &host_url {
+ let all_remotes = local_repo.remotes()?;
+ for remote_name in all_remotes.iter() {
+ let Some(remote_name) = remote_name else {
+ continue;
+ };
+ let remote = local_repo.find_remote(remote_name)?;
+
+ if let Some(url) = remote.url() {
+ let url = keys.deref_alias(crate::ssh_url_parse(url)?);
+ let (url, _) = url_strip_repo_name(url)?;
+ if crate::host_with_port(&url) == crate::host_with_port(&url)
+ && url.path() == host_url.path()
+ {
+ name = Some(remote_name.to_owned());
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if let Some(name) = name {
+ if let Ok(remote) = local_repo.find_remote(&name) {
+ let url_s = std::str::from_utf8(remote.url_bytes())?;
+ let url = keys.deref_alias(crate::ssh_url_parse(url_s)?);
+ let (url, repo_name) = url_strip_repo_name(url)?;
+
+ out = (Some(url), Some(repo_name));
+
+ final_remote_name = Some(name);
+ }
+ }
+ } else {
+ eyre::ensure!(remote.is_none(), "remote specified but no git repo found");
+ }
+ out
+ };
+
+ let (url, name) = if repo_url.is_some() {
+ (repo_url, repo_name)
+ } else if repo_name.is_some() {
+ (host_url.or(remote_url), repo_name)
+ } else if remote.is_some() {
+ (remote_url, remote_repo_name)
+ } else if host_url.is_none() || remote_url == host_url {
+ (remote_url, remote_repo_name)
+ } else {
+ (host_url, None)
+ };
+
+ let url = url.or_else(fallback_host).map(|url| {
+ let mut url = match url.scheme() {
+ "http" | "https" => url,
+ _ => url::Url::parse(&format!("https{}", &url[url::Position::AfterScheme..]))
+ .expect("should always be valid"),
+ };
+ url.set_username("").expect("shouldn't fail");
+ url
+ });
+
+ let info = match (url, name) {
+ (Some(url), name) => RepoInfo {
+ url,
+ name,
+ remote_name: final_remote_name,
+ },
+ (None, Some(_)) => eyre::bail!("cannot find repo, no host specified"),
+ (None, None) => eyre::bail!("no repo info specified"),
+ };
+
+ Ok(info)
+ }
+
+ pub fn name(&self) -> Option<&RepoName> {
+ self.name.as_ref()
+ }
+
+ pub fn host_url(&self) -> &Url {
+ &self.url
+ }
+
+ pub fn remote_name(&self) -> Option<&str> {
+ self.remote_name.as_deref()
+ }
+}
+
+fn fallback_host() -> Option<Url> {
+ if let Some(envvar) = std::env::var_os("FJ_FALLBACK_HOST") {
+ let out = envvar.to_str().and_then(|x| x.parse::<Url>().ok());
+ if out.is_none() {
+ println!("warn: `FJ_FALLBACK_HOST` is not set to a valid url");
+ }
+ out
+ } else {
+ None
+ }
+}
+
+fn url_strip_repo_name(mut url: Url) -> eyre::Result<(Url, RepoName)> {
+ let mut iter = url
+ .path_segments()
+ .ok_or_eyre("repo url cannot be a base")?
+ .rev();
+
+ let name = iter.next().ok_or_eyre("repo url too short")?;
+ let name = name.strip_suffix(".git").unwrap_or(name).to_owned();
+
+ let owner = iter.next().ok_or_eyre("repo url too short")?.to_owned();
+
+ // Remove the username and repo name from the url
+ url.path_segments_mut()
+ .map_err(|_| eyre!("repo url cannot be a base"))?
+ .pop()
+ .pop();
+
+ Ok((url, RepoName { owner, name }))
+}
+
+#[derive(Clone, Debug)]
+pub struct RepoName {
+ owner: String,
+ name: String,
+}
+
+impl RepoName {
+ pub fn new(owner: String, name: String) -> Self {
+ Self { owner, name }
+ }
+ pub fn owner(&self) -> &str {
+ &self.owner
+ }
+
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct RepoArg {
+ host: Option<String>,
+ owner: String,
+ name: String,
+}
+
+impl std::fmt::Display for RepoArg {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match &self.host {
+ Some(host) => write!(f, "{host}/{}/{}", self.owner, self.name),
+ None => write!(f, "{}/{}", self.owner, self.name),
+ }
+ }
+}
+
+impl FromStr for RepoArg {
+ type Err = RepoArgError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let (head, name) = s.rsplit_once("/").ok_or(RepoArgError::NoOwner)?;
+ let name = name.strip_suffix(".git").unwrap_or(name);
+ let (host, owner) = match head.rsplit_once("/") {
+ Some((host, owner)) => (Some(host), owner),
+ None => (None, head),
+ };
+ Ok(Self {
+ host: host.map(|s| s.to_owned()),
+ owner: owner.to_owned(),
+ name: name.to_owned(),
+ })
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RepoArgError {
+ NoOwner,
+}
+
+impl std::error::Error for RepoArgError {}
+
+impl std::fmt::Display for RepoArgError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ RepoArgError::NoOwner => {
+ write!(f, "repo name should be in the format [HOST/]OWNER/NAME")
+ }
+ }
+ }
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum RepoCommand {
+ /// Creates a new repository
+ Create {
+ repo: String,
+
+ // flags
+ #[clap(long, short)]
+ description: Option<String>,
+ #[clap(long, short = 'P')]
+ private: bool,
+ /// Creates a new remote with the given name for the new repo
+ #[clap(long, short)]
+ remote: Option<String>,
+ /// Pushes the current branch to the default branch on the new repo.
+ /// Implies `--remote=origin` (setting remote manually overrides this)
+ #[clap(long, short)]
+ push: bool,
+ },
+ /// Fork a repository onto your account
+ Fork {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ #[clap(long)]
+ name: Option<String>,
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ },
+ Migrate {
+ /// URL of the repo to migrate
+ #[clap(id = "HOST/OWNER/REPO")]
+ repo: String,
+ /// Name of the new mirror
+ name: String,
+ /// Whether to mirror the repo instead of migrating it
+ #[clap(long, short)]
+ mirror: bool,
+ /// Whether the new migration should be private
+ #[clap(long, short)]
+ private: bool,
+ /// Comma-separated list of items to include. Defaults to nothing but git data.
+ ///
+ /// These are `lfs`, `wiki`, `issues`, `prs`, `milestones`, `labels`, and `releases`.
+ /// You can use `all` to include everything.
+ #[clap(long, short)]
+ include: Option<MigrateInclude>,
+ /// The URL to fetch LFS files from
+ #[clap(long, short = 'L')]
+ lfs_endpoint: Option<url::Url>,
+ /// The type of Git service the original repo is on. Defaults to `git`
+ #[clap(long, short)]
+ service: Option<MigrateService>,
+ /// If enabled, will read an access token in from stdin to use for fetching.
+ ///
+ /// Mutually exclusive with `--login`
+ #[clap(long, short)]
+ token: bool,
+ /// If enabled, will read a username and password from stdin to use for fetching.
+ ///
+ /// Mutually exclusive with `--token`.
+ ///
+ /// This is not recommended, `--token` should be used instead whenever possible.
+ #[clap(long, short)]
+ login: bool,
+ },
+ /// View a repo's info
+ View {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ name: Option<RepoArg>,
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ },
+ /// View a repo's README
+ Readme {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ name: Option<RepoArg>,
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ },
+ /// Clone a repo's code locally
+ Clone {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ path: Option<PathBuf>,
+ },
+ /// Add a star to a repo
+ Star {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ },
+ /// Take away a star from a repo
+ Unstar {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ },
+ /// Delete a repository
+ ///
+ /// This cannot be undone!
+ Delete {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ },
+ /// Open a repository's page in your browser
+ Browse {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ name: Option<RepoArg>,
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ },
+}
+
+impl RepoCommand {
+ pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
+ match self {
+ RepoCommand::Create {
+ repo,
+
+ description,
+ private,
+ remote,
+ push,
+ } => {
+ let host = RepoInfo::get_current(host_name, None, None, &keys)?;
+ let api = keys.get_api(host.host_url()).await?;
+ create_repo(&api, repo, description, private, remote, push).await?;
+ }
+ RepoCommand::Fork { repo, name, remote } => {
+ fn strip(s: &str) -> &str {
+ let no_scheme = s
+ .strip_prefix("https://")
+ .or_else(|| s.strip_prefix("http://"))
+ .unwrap_or(s);
+ let no_trailing_slash = no_scheme.strip_suffix("/").unwrap_or(no_scheme);
+ no_trailing_slash
+ }
+ if let (Some(a), Some(b)) = (repo.host.as_deref(), host_name) {
+ if strip(a) != strip(b) {
+ eyre::bail!("conflicting hosts {a} and {b}. please only specify one");
+ }
+ }
+
+ let repo_info =
+ RepoInfo::get_current(host_name, Some(&repo), remote.as_deref(), &keys)?;
+ let api = keys.get_api(repo_info.host_url()).await?;
+ let repo = repo_info
+ .name()
+ .ok_or_eyre("couldn't get repo name, please specify")?;
+ fork_repo(&api, repo, name).await?
+ }
+ RepoCommand::Migrate {
+ repo,
+ name,
+ mirror,
+ private,
+ include,
+ lfs_endpoint,
+ service,
+ token,
+ login,
+ } => {
+ let current_repo = RepoInfo::get_current(host_name, None, None, &keys)?;
+ let api = keys.get_api(current_repo.host_url()).await?;
+ migrate_repo(
+ &api,
+ repo,
+ name,
+ mirror,
+ private,
+ include,
+ lfs_endpoint,
+ service,
+ token,
+ login,
+ )
+ .await?
+ }
+ RepoCommand::View { name, remote } => {
+ let repo =
+ RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref(), &keys)?;
+ let api = keys.get_api(repo.host_url()).await?;
+ let repo = repo
+ .name()
+ .ok_or_eyre("couldn't get repo name, please specify")?;
+ view_repo(&api, repo).await?
+ }
+ RepoCommand::Readme { name, remote } => {
+ let repo =
+ RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref(), &keys)?;
+ let api = keys.get_api(repo.host_url()).await?;
+ let repo = repo
+ .name()
+ .ok_or_eyre("couldn't get repo name, please specify")?;
+ view_repo_readme(&api, repo).await?
+ }
+ RepoCommand::Clone { repo, path } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None, &keys)?;
+ let api = keys.get_api(repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ cmd_clone_repo(&api, name, path).await?;
+ }
+ RepoCommand::Star { repo } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None, &keys)?;
+ let api = keys.get_api(repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ api.user_current_put_star(name.owner(), name.name()).await?;
+ println!("Starred {}/{}!", name.owner(), name.name());
+ }
+ RepoCommand::Unstar { repo } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None, &keys)?;
+ let api = keys.get_api(repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ api.user_current_delete_star(name.owner(), name.name())
+ .await?;
+ println!("Removed star from {}/{}", name.owner(), name.name());
+ }
+ RepoCommand::Delete { repo } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None, &keys)?;
+ let api = keys.get_api(repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ delete_repo(&api, name).await?;
+ }
+ RepoCommand::Browse { name, remote } => {
+ let repo =
+ RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref(), &keys)?;
+ let mut url = repo.host_url().clone();
+ let repo = repo
+ .name()
+ .ok_or_eyre("couldn't get repo name, please specify")?;
+ url.path_segments_mut()
+ .map_err(|_| eyre!("url invalid"))?
+ .extend([repo.owner(), repo.name()]);
+
+ open::that_detached(url.as_str()).wrap_err("Failed to open URL")?;
+ }
+ };
+ Ok(())
+ }
+}
+
+pub async fn create_repo(
+ api: &Forgejo,
+ repo: String,
+ description: Option<String>,
+ private: bool,
+ remote: Option<String>,
+ push: bool,
+) -> eyre::Result<()> {
+ if remote.is_some() || push {
+ let repo = git2::Repository::discover(".")?;
+
+ let upstream = remote.as_deref().unwrap_or("origin");
+ if repo.find_remote(upstream).is_ok() {
+ eyre::bail!("A remote named \"{upstream}\" already exists");
+ }
+ }
+ let repo_spec = CreateRepoOption {
+ auto_init: Some(false),
+ default_branch: Some("main".into()),
+ description,
+ gitignores: None,
+ issue_labels: None,
+ license: None,
+ name: repo,
+ object_format_name: None,
+ private: Some(private),
+ readme: Some(String::new()),
+ template: Some(false),
+ trust_model: Some(forgejo_api::structs::CreateRepoOptionTrustModel::Default),
+ };
+ let new_repo = api.create_current_user_repo(repo_spec).await?;
+ let html_url = new_repo
+ .html_url
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("new_repo does not have html_url"))?;
+ println!("created new repo at {}", html_url);
+
+ if remote.is_some() || push {
+ let repo = git2::Repository::discover(".")?;
+
+ let upstream = remote.as_deref().unwrap_or("origin");
+ let clone_url = new_repo
+ .clone_url
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("new_repo does not have clone_url"))?;
+ let mut remote = repo.remote(upstream, clone_url.as_str())?;
+
+ if push {
+ let head = repo.head()?;
+ if !head.is_branch() {
+ eyre::bail!("HEAD is not on a branch; cannot push to remote");
+ }
+ let branch_shorthand = head
+ .shorthand()
+ .ok_or_else(|| eyre!("branch name invalid utf-8"))?
+ .to_owned();
+ let branch_name = std::str::from_utf8(head.name_bytes())?.to_owned();
+
+ let auth = auth_git2::GitAuthenticator::new();
+ auth.push(&repo, &mut remote, &[&branch_name])?;
+
+ remote.fetch(&[&branch_shorthand], None, None)?;
+
+ let mut current_branch = git2::Branch::wrap(head);
+ current_branch.set_upstream(Some(&format!("{upstream}/{branch_shorthand}")))?;
+ }
+ }
+
+ Ok(())
+}
+
+async fn fork_repo(api: &Forgejo, repo: &RepoName, name: Option<String>) -> eyre::Result<()> {
+ let opt = forgejo_api::structs::CreateForkOption {
+ name,
+ organization: None,
+ };
+ let new_fork = api.create_fork(repo.owner(), repo.name(), opt).await?;
+ let fork_full_name = new_fork
+ .full_name
+ .as_deref()
+ .ok_or_eyre("fork does not have name")?;
+ println!(
+ "Forked {}/{} into {}",
+ repo.owner(),
+ repo.name(),
+ fork_full_name
+ );
+
+ Ok(())
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum, Default)]
+pub enum MigrateService {
+ #[default]
+ Git,
+ Github,
+ Gitlab,
+ Forgejo,
+ Gitea,
+ Gogs,
+ Onedev,
+ Gitbucket,
+ Codebase,
+}
+
+impl FromStr for MigrateService {
+ type Err = MigrateServiceParseError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "git" => Ok(Self::Git),
+ "github" => Ok(Self::Github),
+ "gitlab" => Ok(Self::Gitlab),
+ "forgejo" => Ok(Self::Forgejo),
+ "gitea" => Ok(Self::Gitea),
+ "gogs" => Ok(Self::Gogs),
+ "onedev" | "one-dev" => Ok(Self::Onedev),
+ "gitbucket" | "git-bucket" => Ok(Self::Gitbucket),
+ "codebase" => Ok(Self::Codebase),
+ _ => Err(MigrateServiceParseError),
+ }
+ }
+}
+
+impl MigrateService {
+ fn to_api_type(self) -> forgejo_api::structs::MigrateRepoOptionsService {
+ use forgejo_api::structs::MigrateRepoOptionsService as Api;
+ use MigrateService as Cli;
+ match self {
+ Cli::Git => Api::Git,
+ Cli::Github => Api::Github,
+ Cli::Gitlab => Api::Gitlab,
+ Cli::Forgejo => Api::Gitea,
+ Cli::Gitea => Api::Gitea,
+ Cli::Gogs => Api::Gogs,
+ Cli::Onedev => Api::Onedev,
+ Cli::Gitbucket => Api::Gitbucket,
+ Cli::Codebase => Api::Codebase,
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct MigrateServiceParseError;
+
+impl std::error::Error for MigrateServiceParseError {}
+
+impl std::fmt::Display for MigrateServiceParseError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("unknown service")
+ }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
+pub struct MigrateInclude {
+ lfs: bool,
+ wiki: bool,
+ issues: bool,
+ prs: bool,
+ milestones: bool,
+ labels: bool,
+ releases: bool,
+}
+
+impl MigrateInclude {
+ /// if the selection includes anything other than LFS (which is supported by base git)
+ fn non_base_git(self) -> bool {
+ self.wiki | self.issues | self.prs | self.milestones | self.labels | self.releases
+ }
+}
+
+impl FromStr for MigrateInclude {
+ type Err = MigrateIncludeParseError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ if s == "all" {
+ Ok(Self {
+ lfs: true,
+ wiki: true,
+ issues: true,
+ prs: true,
+ milestones: true,
+ labels: true,
+ releases: true,
+ })
+ } else {
+ let mut out = Self::default();
+ for opt in s.split(",") {
+ match opt {
+ "lfs" => out.lfs = true,
+ "wiki" => out.wiki = true,
+ "issues" => out.issues = true,
+ "prs" => out.prs = true,
+ "milestones" => out.milestones = true,
+ "labels" => out.labels = true,
+ "releases" => out.releases = true,
+ _ => return Err(MigrateIncludeParseError),
+ }
+ }
+ Ok(out)
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct MigrateIncludeParseError;
+
+impl std::error::Error for MigrateIncludeParseError {}
+
+impl std::fmt::Display for MigrateIncludeParseError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("unknown include option")
+ }
+}
+
+async fn migrate_repo(
+ api: &Forgejo,
+ mut repo: String,
+ name: String,
+ mirror: bool,
+ private: bool,
+ include: Option<MigrateInclude>,
+ lfs_endpoint: Option<url::Url>,
+ service: Option<MigrateService>,
+ token: bool,
+ login: bool,
+) -> eyre::Result<()> {
+ let include = include.unwrap_or_default();
+ let service = service.unwrap_or_default();
+
+ if service == MigrateService::Git && include.non_base_git() {
+ eyre::bail!("Migrating from a `git` service doesn't support migration items other than LFS. Please specify a different service or remove the included items");
+ }
+
+ if repo.ends_with("/") {
+ let _ = repo.pop();
+ }
+ if !repo.ends_with(".git") {
+ repo.push_str(".git");
+ }
+ let clone_url =
+ url::Url::parse(&repo).or_else(|_| url::Url::parse(&format!("https://{repo}")))?;
+
+ let (username, password) = if login {
+ let username = crate::readline("Username: ").await?.trim().to_owned();
+ let password = crate::readline("Password: ").await?.trim().to_owned();
+ (Some(username), Some(password))
+ } else {
+ (None, None)
+ };
+
+ let auth_token = if token {
+ let auth_token = crate::readline("Token: ").await?.trim().to_owned();
+ Some(auth_token.trim().to_owned())
+ } else {
+ None
+ };
+
+ let migrate_options = forgejo_api::structs::MigrateRepoOptions {
+ auth_password: password,
+ auth_username: username,
+ auth_token,
+ clone_addr: clone_url.as_str().to_owned(),
+ description: None,
+ issues: Some(include.issues),
+ labels: Some(include.labels),
+ lfs: Some(include.lfs),
+ lfs_endpoint: lfs_endpoint.map(|url| url.to_string()),
+ milestones: Some(include.milestones),
+ mirror: Some(mirror),
+ mirror_interval: None,
+ private: Some(private),
+ pull_requests: Some(include.prs),
+ releases: Some(include.releases),
+ repo_name: name,
+ repo_owner: None,
+ service: Some(service.to_api_type()),
+ uid: None,
+ wiki: Some(include.wiki),
+ };
+
+ println!("Migrating...");
+ let new_repo = api.repo_migrate(migrate_options).await?;
+ let new_repo_url = new_repo
+ .html_url
+ .as_ref()
+ .ok_or_eyre("new repo doesnt have url")?;
+ println!("Done! View online at {new_repo_url}");
+
+ Ok(())
+}
+
+async fn view_repo(api: &Forgejo, repo: &RepoName) -> eyre::Result<()> {
+ let repo = api.repo_get(repo.owner(), repo.name()).await?;
+
+ let SpecialRender {
+ dash,
+ body_prefix,
+ dark_grey,
+ reset,
+ ..
+ } = crate::special_render();
+
+ println!("{}", repo.full_name.ok_or_eyre("no full name")?);
+
+ if let Some(parent) = &repo.parent {
+ println!(
+ "Fork of {}",
+ parent.full_name.as_ref().ok_or_eyre("no full name")?
+ );
+ }
+ if repo.mirror == Some(true) {
+ if let Some(original) = &repo.original_url {
+ println!("Mirror of {original}")
+ }
+ }
+ let desc = repo.description.as_deref().unwrap_or_default();
+ // Don't use body::markdown, this is plain text.
+ if !desc.is_empty() {
+ if desc.lines().count() > 1 {
+ println!();
+ }
+ for line in desc.lines() {
+ println!("{dark_grey}{body_prefix}{reset} {line}");
+ }
+ }
+ println!();
+
+ let lang = repo.language.as_deref().unwrap_or_default();
+ if !lang.is_empty() {
+ println!("Primary language is {lang}");
+ }
+
+ let stars = repo.stars_count.unwrap_or_default();
+ if stars == 1 {
+ print!("{stars} star {dash} ");
+ } else {
+ print!("{stars} stars {dash} ");
+ }
+
+ let watchers = repo.watchers_count.unwrap_or_default();
+ print!("{watchers} watching {dash} ");
+
+ let forks = repo.forks_count.unwrap_or_default();
+ if forks == 1 {
+ print!("{forks} fork");
+ } else {
+ print!("{forks} forks");
+ }
+ println!();
+
+ let mut first = true;
+ if repo.has_issues.unwrap_or_default() && repo.external_tracker.is_none() {
+ let issues = repo.open_issues_count.unwrap_or_default();
+ if issues == 1 {
+ print!("{issues} issue");
+ } else {
+ print!("{issues} issues");
+ }
+ first = false;
+ }
+ if repo.has_pull_requests.unwrap_or_default() {
+ if !first {
+ print!(" {dash} ");
+ }
+ let pulls = repo.open_pr_counter.unwrap_or_default();
+ if pulls == 1 {
+ print!("{pulls} PR");
+ } else {
+ print!("{pulls} PRs");
+ }
+ first = false;
+ }
+ if repo.has_releases.unwrap_or_default() {
+ if !first {
+ print!(" {dash} ");
+ }
+ let releases = repo.release_counter.unwrap_or_default();
+ if releases == 1 {
+ print!("{releases} release");
+ } else {
+ print!("{releases} releases");
+ }
+ first = false;
+ }
+ if !first {
+ println!();
+ }
+ if let Some(external_tracker) = &repo.external_tracker {
+ if let Some(tracker_url) = &external_tracker.external_tracker_url {
+ println!("Issue tracker is at {tracker_url}");
+ }
+ }
+
+ if let Some(html_url) = &repo.html_url {
+ println!();
+ println!("View online at {html_url}");
+ }
+
+ Ok(())
+}
+
+async fn view_repo_readme(api: &Forgejo, repo: &RepoName) -> eyre::Result<()> {
+ let query = forgejo_api::structs::RepoGetRawFileQuery { r#ref: None };
+ let file = api
+ .repo_get_raw_file(repo.owner(), repo.name(), "README.md", query)
+ .await;
+ if let Ok(readme) = file {
+ let readme_str = String::from_utf8_lossy(&readme);
+ println!("{}", crate::markdown(&readme_str));
+ return Ok(());
+ } else {
+ let query = forgejo_api::structs::RepoGetRawFileQuery { r#ref: None };
+ let file = api
+ .repo_get_raw_file(repo.owner(), repo.name(), "README.txt", query)
+ .await;
+ if let Ok(readme) = file {
+ let readme_str = String::from_utf8_lossy(&readme);
+ println!("{}", crate::render_text(&readme_str));
+ return Ok(());
+ }
+ }
+ eyre::bail!("Repo does not have README.md or README.txt");
+}
+
+async fn cmd_clone_repo(
+ api: &Forgejo,
+ name: &RepoName,
+ path: Option<std::path::PathBuf>,
+) -> eyre::Result<()> {
+ let repo_data = api.repo_get(name.owner(), name.name()).await?;
+ let clone_url = repo_data
+ .clone_url
+ .as_ref()
+ .ok_or_eyre("repo does not have clone url")?;
+
+ let repo_name = repo_data
+ .name
+ .as_deref()
+ .ok_or_eyre("repo does not have name")?;
+ let repo_full_name = repo_data
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+
+ let path = path.unwrap_or_else(|| PathBuf::from(format!("./{repo_name}")));
+
+ let local_repo = clone_repo(repo_full_name, clone_url, &path)?;
+
+ if let Some(parent) = repo_data.parent.as_deref() {
+ let parent_clone_url = parent
+ .clone_url
+ .as_ref()
+ .ok_or_eyre("parent repo does not have clone url")?;
+ local_repo.remote("upstream", parent_clone_url.as_str())?;
+ }
+
+ Ok(())
+}
+
+pub fn clone_repo(
+ repo_name: &str,
+ url: &url::Url,
+ path: &std::path::Path,
+) -> eyre::Result<git2::Repository> {
+ let SpecialRender {
+ fancy,
+ hide_cursor,
+ show_cursor,
+ clear_line,
+ ..
+ } = *crate::special_render();
+
+ let auth = auth_git2::GitAuthenticator::new();
+ let git_config = git2::Config::open_default()?;
+
+ let mut options = git2::FetchOptions::new();
+ let mut callbacks = git2::RemoteCallbacks::new();
+ callbacks.credentials(auth.credentials(&git_config));
+
+ if fancy {
+ print!("{hide_cursor}");
+ print!(" Preparing...");
+ let _ = std::io::stdout().flush();
+
+ callbacks.transfer_progress(|progress| {
+ print!("{clear_line}\r");
+ if progress.received_objects() == progress.total_objects() {
+ if progress.indexed_deltas() == progress.total_deltas() {
+ print!("Finishing up...");
+ } else {
+ let percent = 100.0 * (progress.indexed_deltas() as f64)
+ / (progress.total_deltas() as f64);
+ print!(" Resolving... {percent:.01}%");
+ }
+ } else {
+ let bytes = progress.received_bytes();
+ let percent = 100.0 * (progress.received_objects() as f64)
+ / (progress.total_objects() as f64);
+ print!(" Downloading... {percent:.01}%");
+ match bytes {
+ 0..=1023 => print!(" ({}b)", bytes),
+ 1024..=1048575 => print!(" ({:.01}kb)", (bytes as f64) / 1024.0),
+ 1048576..=1073741823 => {
+ print!(" ({:.01}mb)", (bytes as f64) / 1048576.0)
+ }
+ 1073741824.. => {
+ print!(" ({:.01}gb)", (bytes as f64) / 1073741824.0)
+ }
+ }
+ }
+ let _ = std::io::stdout().flush();
+ true
+ });
+ options.remote_callbacks(callbacks);
+ }
+
+ let local_repo = git2::build::RepoBuilder::new()
+ .fetch_options(options)
+ .clone(url.as_str(), path)?;
+ if fancy {
+ print!("{clear_line}{show_cursor}\r");
+ }
+ println!("Cloned {} into {}", repo_name, path.display());
+ Ok(local_repo)
+}
+
+async fn delete_repo(api: &Forgejo, name: &RepoName) -> eyre::Result<()> {
+ print!(
+ "Are you sure you want to delete {}/{}? (y/N) ",
+ name.owner(),
+ name.name()
+ );
+ let user_response = crate::readline("").await?;
+ let yes = matches!(user_response.trim(), "y" | "Y" | "yes" | "Yes");
+ if yes {
+ api.repo_delete(name.owner(), name.name()).await?;
+ println!("Deleted {}/{}", name.owner(), name.name());
+ } else {
+ println!("Did not delete");
+ }
+ Ok(())
+}