//! Where a new project comes from, as the Add project palette offers it: a //! folder already on disk, a new empty repository, or a clone. A host runs //! the same code when a connected device adds a project on it. use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use serde::{Deserialize, Serialize}; use crate::ui::folder_browser::expand; use crate::ui::workspace::find_program; /// A hosted Git service to clone `owner/repo` from. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Forge { GitHub, GitLab, AzureDevOps, Bitbucket, Forgejo, } impl Forge { pub const ALL: [Forge; 5] = [Forge::GitHub, Forge::GitLab, Forge::AzureDevOps, Forge::Bitbucket, Forge::Forgejo]; pub fn label(self) -> &'static str { match self { Forge::GitHub => "GitHub repository", Forge::GitLab => "GitLab repository", Forge::AzureDevOps => "Azure DevOps repository", Forge::Bitbucket => "Bitbucket repository", Forge::Forgejo => "Forgejo / Gitea repository", } } /// What the clone field asks for. pub fn placeholder(self) -> &'static str { match self { Forge::AzureDevOps => "organization/project/repo", Forge::Bitbucket => "workspace/repo", _ => "owner/repo", } } pub fn description(self) -> String { match self { Forge::GitHub => "Clone a GitHub owner/repo".into(), Forge::GitLab => "Clone a GitLab group/repo".into(), Forge::AzureDevOps => "Clone Azure DevOps project/repository".into(), Forge::Bitbucket => "Clone Bitbucket workspace/repository".into(), Forge::Forgejo => "Clone Forgejo / Gitea owner/repo".into(), } } /// The command-line tool that signs in to it, if cloning needs one. pub fn cli(self) -> Option<&'static str> { match self { Forge::GitHub => Some("gh"), Forge::GitLab => Some("glab"), Forge::Forgejo => Some("tea"), Forge::AzureDevOps | Forge::Bitbucket => None, } } /// What to do before it can clone, when its tool is missing. pub fn setup_hint(self) -> &'static str { match self { Forge::GitHub => "Install the GitHub CLI (gh) and run gh auth login.", Forge::GitLab => "Install the GitLab CLI (glab) and run glab auth login.", Forge::Forgejo => "Install the Gitea CLI (tea) and run tea login add.", Forge::AzureDevOps | Forge::Bitbucket => "", } } /// Whether its tool is installed here. pub fn ready(self) -> bool { self.cli().is_none_or(|cli| find_program(&[cli]).is_some()) } } /// What the user picked in the Add project palette. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ProjectSource { /// A folder that's already there, as typed: `~/Work/app`. Folder { path: String }, /// A new folder in the base directory, with an empty repository. New { name: String }, /// A clone of a Git URL. Url { url: String }, /// A clone of a repository on a forge. Forge { forge: Forge, repo: String }, } impl ProjectSource { /// Clones take a while, so they run in the background. pub fn is_clone(&self) -> bool { matches!(self, ProjectSource::Url { .. } | ProjectSource::Forge { .. }) } /// The folder a clone or new project lands in, under `base`. pub fn destination(&self, base: &Path) -> Option { let name = match self { ProjectSource::Folder { .. } => return None, ProjectSource::New { name } => name.trim().to_string(), ProjectSource::Url { url } => repo_name(url)?, ProjectSource::Forge { repo, .. } => repo_name(repo)?, }; Some(base.join(name)) } } /// The folder a repository clones into: its URL's last part, without `.git`. pub fn repo_name(url: &str) -> Option { let url = url.trim().trim_end_matches('/'); let last = url.rsplit(['/', ':']).next()?; let name = last.strip_suffix(".git").unwrap_or(last); (!name.is_empty() && name != "." && name != "..").then(|| name.to_string()) } /// Why a new project's name won't do, if it won't. pub fn check_name(name: &str) -> Option<&'static str> { let name = name.trim(); if name.is_empty() { Some("Type a name for the project.") } else if name == "." || name == ".." || name.contains('/') || name.contains('\\') { Some("A project name can't contain slashes.") } else { None } } /// Makes the source a project folder on this device and returns it. New /// projects and clones go in `base`. Clones block until git is done. pub fn resolve(source: &ProjectSource, base: &Path, home: &Path) -> Result { match source { ProjectSource::Folder { path } => { let path = expand(path.trim(), home); if path.is_dir() { Ok(path) } else { Err(format!("{} isn't a folder.", path.display())) } } ProjectSource::New { name } => { if let Some(problem) = check_name(name) { return Err(problem.into()); } let path = base.join(name.trim()); if path.exists() { return Err(format!("{} already exists.", path.display())); } std::fs::create_dir_all(&path).map_err(|err| format!("Couldn't create {}: {err}", path.display()))?; if let Some(git) = find_program(&["git"]) { run(Command::new(git).arg("init").arg("--quiet").arg(&path))?; } Ok(path) } ProjectSource::Url { url } => { let dest = clone_destination(source, base)?; let git = find_program(&["git"]).ok_or("Git isn't installed.")?; run(Command::new(git).args(["clone", "--quiet", "--", url.trim()]).arg(&dest))?; Ok(dest) } ProjectSource::Forge { forge, repo } => { let dest = clone_destination(source, base)?; let repo = repo.trim().trim_matches('/'); let mut command = match forge.cli() { Some(cli) => { let program = find_program(&[cli]).ok_or_else(|| forge.setup_hint().to_string())?; let mut command = Command::new(program); match forge { Forge::Forgejo => command.args(["clone", repo]).arg(&dest), _ => command.args(["repo", "clone", repo]).arg(&dest), }; command } None => { let url = match forge { Forge::AzureDevOps => { let parts: Vec<&str> = repo.split('/').collect(); let [org, project, name] = parts.as_slice() else { return Err("Type organization/project/repo.".into()); }; format!("https://dev.azure.com/{org}/{project}/_git/{name}") } _ => format!("https://bitbucket.org/{repo}.git"), }; let git = find_program(&["git"]).ok_or("Git isn't installed.")?; let mut command = Command::new(git); command.args(["clone", "--quiet", "--", &url]).arg(&dest); command } }; run(&mut command)?; Ok(dest) } } } fn clone_destination(source: &ProjectSource, base: &Path) -> Result { let dest = source.destination(base).ok_or("That doesn't look like a repository.")?; if dest.exists() { return Err(format!("{} already exists.", dest.display())); } std::fs::create_dir_all(base).map_err(|err| format!("Couldn't create {}: {err}", base.display()))?; Ok(dest) } /// Runs a command without a terminal, so a missing login fails instead of /// waiting for a password no one can type. fn run(command: &mut Command) -> Result<(), String> { command.stdin(Stdio::null()).env("GIT_TERMINAL_PROMPT", "0"); if std::env::var_os("GIT_SSH_COMMAND").is_none() { command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"); } let output = command.output().map_err(|err| err.to_string())?; if output.status.success() { return Ok(()); } let stderr = String::from_utf8_lossy(&output.stderr); let message = stderr.lines().map(str::trim).rfind(|line| !line.is_empty()).unwrap_or("it failed"); Err(message.to_string()) } #[cfg(test)] mod tests { use super::{Forge, ProjectSource, check_name, repo_name}; use std::path::{Path, PathBuf}; #[test] fn names_repositories() { assert_eq!(repo_name("https://github.com/zed-industries/zed.git").as_deref(), Some("zed")); assert_eq!(repo_name("https://github.com/zed-industries/zed/").as_deref(), Some("zed")); assert_eq!(repo_name("git@github.com:owner/app.git").as_deref(), Some("app")); assert_eq!(repo_name("git@host:app.git").as_deref(), Some("app")); assert_eq!(repo_name("owner/repo").as_deref(), Some("repo")); assert_eq!(repo_name(""), None); assert_eq!(repo_name("https://host/.."), None); } #[test] fn checks_names() { assert!(check_name("app").is_none()); assert!(check_name(" ").is_some()); assert!(check_name("a/b").is_some()); assert!(check_name("..").is_some()); } #[test] fn places_clones_in_the_base() { let base = Path::new("/home/ada/Work"); let url = ProjectSource::Url { url: "https://example.com/x/app.git".into() }; assert_eq!(url.destination(base), Some(PathBuf::from("/home/ada/Work/app"))); let forge = ProjectSource::Forge { forge: Forge::GitHub, repo: "owner/tool".into() }; assert_eq!(forge.destination(base), Some(PathBuf::from("/home/ada/Work/tool"))); assert_eq!(ProjectSource::Folder { path: "~/x".into() }.destination(base), None); } }