use std::{io::Write, path::PathBuf, str::FromStr}; use clap::Subcommand; use eyre::{eyre, OptionExt}; use forgejo_api::{structs::CreateRepoOption, Forgejo}; use url::Url; use crate::SpecialRender; pub struct RepoInfo { url: Url, name: Option, } impl RepoInfo { pub fn get_current( host: Option<&str>, repo: Option<&RepoArg>, remote: Option<&str>, keys: &crate::keys::KeyInfo, ) -> eyre::Result { // 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 = None; let mut repo_name: Option = 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 (remote_url, remote_repo_name) = { let mut out = (None, None); if let Ok(local_repo) = git2::Repository::open(".") { 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, name) = url_strip_repo_name(url)?; out = (Some(url), 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 }, (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 } } fn fallback_host() -> Option { if let Some(envvar) = std::env::var_os("FJ_FALLBACK_HOST") { let out = envvar.to_str().and_then(|x| x.parse::().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, 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 { 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, #[clap(long, short = 'P')] private: bool, /// Creates a new remote with the given name for the new repo #[clap(long, short)] remote: Option, /// 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, #[clap(long, short = 'R')] remote: Option, }, /// View a repo's info View { #[clap(id = "[HOST/]OWNER/REPO")] name: Option, #[clap(long, short = 'R')] remote: Option, }, /// Clone a repo's code locally Clone { #[clap(id = "[HOST/]OWNER/REPO")] repo: RepoArg, path: Option, }, /// 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, #[clap(long, short = 'R')] remote: Option, }, } 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::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::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(url.as_str())?; } }; Ok(()) } } pub async fn create_repo( api: &Forgejo, repo: String, description: Option, private: bool, remote: Option, push: bool, ) -> eyre::Result<()> { if remote.is_some() || push { let repo = git2::Repository::open(".")?; 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::open(".")?; 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) -> 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(()) } 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 cmd_clone_repo( api: &Forgejo, name: &RepoName, path: Option, ) -> 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 { 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(()) }