//! 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<PathBuf> {
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<String> {
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<PathBuf, String> {
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<PathBuf, String> {
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);
}
}