diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0158e3e431..541cda5f36 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,6 +40,8 @@ jobs: uses: taiki-e/install-action@v2 with: tool: cargo-insta + - name: Setup Mercurial (macos) + run: brew install mercurial - name: Check run: make check - name: Test diff --git a/docs/guide/commands/init.md b/docs/guide/commands/init.md index a4da232996..40f4ab5417 100644 --- a/docs/guide/commands/init.md +++ b/docs/guide/commands/init.md @@ -33,6 +33,10 @@ success: Initialized project in /Users/john/Development/my-project. [possible values: `hatchling`, `setuptools`, `flit`, `pdm`, `maturin`] +* `--vcs `: Which version control system should be used (defaults to git)? + + [possible values: `git`. `mercurial`, `none`] + * `--license `: Which license should be used? [SPDX identifier](https://spdx.org/licenses/) * `--name `: The name of the package diff --git a/docs/guide/config.md b/docs/guide/config.md index bda7499c7c..5be54a7fd7 100644 --- a/docs/guide/config.md +++ b/docs/guide/config.md @@ -54,6 +54,9 @@ toolchain = "cpython@3.11.1" # This is the default build system that is used build-system = "hatchling" +# This is the default version control system that is used +vcs = "git" + # This is the default license that is used license = "MIT" diff --git a/rye/src/cli/init.rs b/rye/src/cli/init.rs index e88608357a..78349d1406 100644 --- a/rye/src/cli/init.rs +++ b/rye/src/cli/init.rs @@ -19,15 +19,15 @@ use tempfile::tempdir; use crate::bootstrap::ensure_self_venv; use crate::config::Config; use crate::platform::{ - get_default_author_with_fallback, get_latest_cpython_version, get_pinnable_version, - get_python_version_request_from_pyenv_pin, + get_latest_cpython_version, get_pinnable_version, get_python_version_request_from_pyenv_pin, }; use crate::pyproject::BuildSystem; use crate::sources::py::PythonVersionRequest; use crate::utils::{ - copy_dir, escape_string, format_requirement, get_venv_python_bin, is_inside_git_work_tree, - CommandOutput, CopyDirOptions, IoPathContext, + copy_dir, escape_string, format_requirement, get_venv_python_bin, CommandOutput, + CopyDirOptions, IoPathContext, }; +use crate::vcs::ProjectVCS; /// Initialize a new or existing Python project with Rye. #[derive(Parser, Debug)] @@ -83,6 +83,9 @@ pub struct Args { /// Turns off all output. #[arg(short, long, conflicts_with = "verbose")] quiet: bool, + /// Which VCS should be used? (defaults to git) + #[arg(long)] + vcs: Option, } #[derive(Parser, Debug)] @@ -128,9 +131,6 @@ const RUST_INIT_PY_TEMPLATE: &str = include_str!("../templates/lib/maturin/__ini /// Template for the Cargo.toml. const CARGO_TOML_TEMPLATE: &str = include_str!("../templates/lib/maturin/Cargo.toml.j2"); -/// Template for fresh gitignore files. -const GITIGNORE_TEMPLATE: &str = include_str!("../templates/gitignore.j2"); - /// Script used for setup.py setup proxy. const SETUP_PY_PROXY_SCRIPT: &str = r#" import json, sys @@ -199,8 +199,13 @@ pub fn execute(cmd: Args) -> Result<(), Error> { .unwrap_or_else(|| "unknown".into()) })); + let project_vcs = match cmd.vcs { + Some(project_vcs) => project_vcs, + None => cfg.default_vcs().unwrap_or(ProjectVCS::Git), + }; + let version = "0.1.0"; - let author = get_default_author_with_fallback(&dir); + let author = flat_author(project_vcs.get_author(&dir, cfg.default_author())); let license = match cmd.license { Some(license) => Some(license), None => cfg.default_license(), @@ -326,36 +331,10 @@ pub fn execute(cmd: Args) -> Result<(), Error> { name_safe.insert(0, '_'); } - // if git init is successful prepare the local git repository - if !is_inside_git_work_tree(&dir) - && Command::new("git") - .arg("init") - .current_dir(&dir) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) - && is_metadata_author_none + if !project_vcs.inside_work_tree(&dir) && project_vcs.init_dir(&dir) && is_metadata_author_none { - let new_author = get_default_author_with_fallback(&dir); - if author != new_author { - metadata.author = new_author; - } - } - - let gitignore = dir.join(".gitignore"); - - // create a .gitignore if one is missing - if !gitignore.is_file() { - let rv = env.render_named_str( - "gitignore.txt", - GITIGNORE_TEMPLATE, - context! { - is_rust => matches!(build_system, BuildSystem::Maturin) - }, - )?; - fs::write(&gitignore, rv).path_context(&gitignore, "failed to write .gitignore")?; + metadata.author = flat_author(project_vcs.get_author(&dir, cfg.default_author())); + project_vcs.render_templates(&dir, &env, build_system)?; } let rv = env.render_named_str( @@ -457,6 +436,15 @@ pub fn execute(cmd: Args) -> Result<(), Error> { Ok(()) } +fn flat_author(author: (Option, Option)) -> Option<(String, String)> { + match author { + (Some(name), Some(email)) => Some((name, email)), + (Some(name), None) => Some((name, "unknown@domain.invalid".to_string())), + (None, Some(email)) => Some(("Unknown".to_string(), email)), + _ => None, + } +} + #[derive(Default)] struct Metadata { name: Option, diff --git a/rye/src/config.rs b/rye/src/config.rs index dd173268d6..0a70f1c108 100644 --- a/rye/src/config.rs +++ b/rye/src/config.rs @@ -12,6 +12,7 @@ use crate::platform::{get_app_dir, get_latest_cpython_version}; use crate::pyproject::{BuildSystem, SourceRef, SourceRefType}; use crate::sources::py::PythonVersionRequest; use crate::utils::{toml, IoPathContext}; +use crate::vcs::ProjectVCS; static CONFIG: Mutex>> = Mutex::new(None); static AUTHOR_REGEX: Lazy = @@ -126,6 +127,18 @@ impl Config { } } + pub fn default_vcs(&self) -> Option { + match self + .doc + .get("default") + .and_then(|x| x.get("vcs")) + .and_then(|x| x.as_str()) + { + Some(vcs) => vcs.parse::().ok(), + None => None, + } + } + /// Returns the default license pub fn default_license(&self) -> Option { self.doc diff --git a/rye/src/main.rs b/rye/src/main.rs index 0a4fb23ee2..dbb7c81220 100644 --- a/rye/src/main.rs +++ b/rye/src/main.rs @@ -19,6 +19,7 @@ mod sources; mod sync; mod utils; mod uv; +mod vcs; static SHOW_CONTINUE_PROMPT: AtomicBool = AtomicBool::new(false); static DISABLE_CTRLC_HANDLER: AtomicBool = AtomicBool::new(false); diff --git a/rye/src/platform.rs b/rye/src/platform.rs index ed663f9066..cb2294af55 100644 --- a/rye/src/platform.rs +++ b/rye/src/platform.rs @@ -1,11 +1,9 @@ use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; use std::sync::Mutex; use std::{env, fs}; use anyhow::{anyhow, Context, Error}; -use crate::config::Config; use crate::pyproject::latest_available_python_version; use crate::sources::py::{PythonVersion, PythonVersionRequest}; use crate::utils::IoPathContext; @@ -177,39 +175,6 @@ pub fn list_known_toolchains() -> Result, Error> { Ok(rv) } -/// Returns the default author from git or the config. -pub fn get_default_author_with_fallback(dir: &PathBuf) -> Option<(String, String)> { - let (mut name, mut email) = Config::current().default_author(); - let is_name_none = name.is_none(); - let is_email_none = email.is_none(); - - if let Ok(rv) = Command::new("git") - .arg("config") - .arg("--get-regexp") - .current_dir(dir) - .arg("^user.(name|email)$") - .stdout(Stdio::piped()) - .output() - { - for line in std::str::from_utf8(&rv.stdout).ok()?.lines() { - match line.split_once(' ') { - Some((key, value)) if key == "user.email" && is_email_none => { - email = Some(value.to_string()); - } - Some((key, value)) if key == "user.name" && is_name_none => { - name = Some(value.to_string()); - } - _ => {} - } - } - } - - Some(( - name?, - email.unwrap_or_else(|| "unknown@domain.invalid".into()), - )) -} - /// Reads the current `.python-version` file. pub fn get_python_version_request_from_pyenv_pin(root: &Path) -> Option { let mut here = root.to_owned(); diff --git a/rye/src/templates/hgignore.j2 b/rye/src/templates/hgignore.j2 new file mode 100644 index 0000000000..dbd2385665 --- /dev/null +++ b/rye/src/templates/hgignore.j2 @@ -0,0 +1,15 @@ +# python generated files +__pycache__/ +.*\.py[oc] +build/ +dist/ +wheels/ +.*\.egg-info + +{%- if is_rust %} +# Rust +target/ +{%- endif %} + +# venv +\.venv diff --git a/rye/src/utils/mod.rs b/rye/src/utils/mod.rs index fc431a98c7..34de7acc00 100644 --- a/rye/src/utils/mod.rs +++ b/rye/src/utils/mod.rs @@ -3,7 +3,9 @@ use std::convert::Infallible; use std::ffi::OsString; use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus, Stdio}; +#[cfg(windows)] +use std::process::Stdio; +use std::process::{Command, ExitStatus}; use std::{fmt, fs}; use anyhow::{anyhow, bail, Context, Error}; @@ -392,18 +394,6 @@ pub fn get_venv_python_bin(venv_path: &Path) -> PathBuf { py } -pub fn is_inside_git_work_tree(dir: &PathBuf) -> bool { - Command::new("git") - .arg("rev-parse") - .arg("--is-inside-work-tree") - .current_dir(dir) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) -} - /// Returns a success exit status. pub fn success_status() -> ExitStatus { #[cfg(windows)] @@ -576,19 +566,3 @@ mod test_expand_env_vars { assert_eq!("This string has an env var: Example value", output); } } - -#[cfg(test)] -mod test_is_inside_git_work_tree { - use std::path::PathBuf; - - use super::is_inside_git_work_tree; - #[test] - fn test_is_inside_git_work_tree_true() { - assert!(is_inside_git_work_tree(&PathBuf::from("."))); - } - - #[test] - fn test_is_inside_git_work_tree_false() { - assert!(!is_inside_git_work_tree(&PathBuf::from("/"))); - } -} diff --git a/rye/src/vcs.rs b/rye/src/vcs.rs new file mode 100644 index 0000000000..1469d62e7f --- /dev/null +++ b/rye/src/vcs.rs @@ -0,0 +1,409 @@ +use crate::utils::IoPathContext; +use anyhow::{anyhow, Error}; +use clap::ValueEnum; +use minijinja::Environment; +use serde::Serialize; +use std::fs; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::str::FromStr; + +/// Template for fresh gitignore files. +const GITIGNORE_TEMPLATE: &str = include_str!("templates/gitignore.j2"); + +// Template for initial hgignore file +const HGIGNORE_TEMPLATE: &str = include_str!("templates/hgignore.j2"); + +#[derive(ValueEnum, Copy, Clone, Serialize, Debug, PartialEq)] +#[value(rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum ProjectVCS { + None, + Git, + Mercurial, +} + +impl FromStr for ProjectVCS { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "none" => Ok(ProjectVCS::None), + "git" => Ok(ProjectVCS::Git), + "mercurial" => Ok(ProjectVCS::Mercurial), + _ => Err(anyhow!("unknown VCS")), + } + } +} + +// Implement this trait for each VCS type and add mapping to ProjectVCS impl methods +trait VCSInfo { + // Used to check whether a dir is already a VCS working tree. True if yes. + fn inside_work_tree(dir: &Path) -> bool; + // Used to init a new VCS repository in a dir. True if successful. + fn init_dir(dir: &Path) -> bool; + // Used to get author info from VCS metadata, for use in project metadata. Return (name?, email?) + fn get_author( + dir: &Path, + or_defaults: (Option, Option), + ) -> (Option, Option); + // Run after init_dir to render any VCS-specific templates. + fn render_templates( + dir: &Path, + env: &Environment, + context: S, + ) -> Result<(), Error>; +} + +struct Git; +impl VCSInfo for Git { + fn inside_work_tree(dir: &Path) -> bool { + command_silent_as_bool( + Command::new("git") + .arg("rev-parse") + .arg("--is-inside-work-tree") + .current_dir(dir), + ) + } + + fn init_dir(dir: &Path) -> bool { + command_silent_as_bool(Command::new("git").arg("init").current_dir(dir)) + } + + fn get_author( + dir: &Path, + or_defaults: (Option, Option), + ) -> (Option, Option) { + let (default_name, default_email) = or_defaults; + let mut name: Option = None; + let mut email: Option = None; + if let Ok(rv) = Command::new("git") + .current_dir(dir) + .arg("config") + .arg("--get-regexp") + .arg("^user.(name|email)$") + .stdout(Stdio::piped()) + .output() + { + let command_output = std::str::from_utf8(&rv.stdout); + match command_output { + Err(_) => {} + Ok(output) => { + for line in output.lines() { + match line.split_once(' ') { + Some((_, "")) => {} + + Some(("user.email", value)) => { + email = Some(value.to_string()); + } + Some(("user.name", value)) => { + name = Some(value.to_string()); + } + _ => {} + } + } + } + } + } + (name.or(default_name), email.or(default_email)) + } + + fn render_templates( + dir: &Path, + env: &Environment, + context: S, + ) -> Result<(), Error> { + render_ignore_file(dir, env, context, ".gitignore", GITIGNORE_TEMPLATE) + } +} + +struct Mercurial; + +impl VCSInfo for Mercurial { + fn inside_work_tree(dir: &Path) -> bool { + command_silent_as_bool(Command::new("hg").arg("root").current_dir(dir)) + } + + fn init_dir(dir: &Path) -> bool { + command_silent_as_bool(Command::new("hg").arg("init").current_dir(dir)) + } + + fn get_author( + dir: &Path, + or_defaults: (Option, Option), + ) -> (Option, Option) { + let (default_name, default_email) = or_defaults; + let mut name: Option = None; + let mut email: Option = None; + if let Ok(rv) = Command::new("hg") + .current_dir(dir) + .arg("config") + .arg("get") + .arg("ui.username") + .arg("ui.email") + .stdout(Stdio::piped()) + .output() + { + let command_output = std::str::from_utf8(&rv.stdout); + match command_output { + Err(_) => {} + Ok(output) => { + for line in output.lines() { + match line.split_once('=') { + Some((_, "")) => {} + Some(("ui.email", value)) => { + email = Some(value.to_string()); + } + Some(("ui.username", value)) => { + name = Some(value.to_string()); + } + _ => {} + } + } + } + } + } + (name.or(default_name), email.or(default_email)) + } + + fn render_templates( + dir: &Path, + env: &Environment, + context: S, + ) -> Result<(), Error> { + render_ignore_file(dir, env, context, ".hgignore", HGIGNORE_TEMPLATE) + } +} + +impl ProjectVCS { + // Is this dir inside a VCS working dir of this type? + pub fn inside_work_tree(&self, dir: &Path) -> bool { + match self { + ProjectVCS::None => false, + ProjectVCS::Mercurial => Mercurial::inside_work_tree(dir), + ProjectVCS::Git => Git::inside_work_tree(dir), + } + } + + // Initialize dir as self type VCS repository + pub fn init_dir(&self, dir: &Path) -> bool { + match self { + ProjectVCS::None => true, + ProjectVCS::Git => Git::init_dir(dir), + ProjectVCS::Mercurial => Mercurial::init_dir(dir), + } + } + + // Returns author in metadata form: (name, email) tuple from vcs, if it exists. + pub fn get_author( + &self, + dir: &Path, + or_defaults: (Option, Option), + ) -> (Option, Option) { + match self { + ProjectVCS::None => or_defaults, + ProjectVCS::Git => Git::get_author(dir, or_defaults), + ProjectVCS::Mercurial => Mercurial::get_author(dir, or_defaults), + } + } + + // Render the support templates for this VCS to given dir + pub fn render_templates( + &self, + dir: &Path, + env: &Environment, + context: S, + ) -> Result<(), Error> { + match self { + ProjectVCS::None => Ok(()), + ProjectVCS::Git => Git::render_templates(dir, env, context), + ProjectVCS::Mercurial => Mercurial::render_templates(dir, env, context), + } + } +} + +// maybe util +fn command_silent_as_bool(cmd: &mut Command) -> bool { + cmd.stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn render_ignore_file( + dir: &Path, + env: &Environment, + context: S, + ignore_filename: &str, + ignore_template: &str, +) -> Result<(), Error> { + let vcs_ignore_path = dir.join(ignore_filename); + if !vcs_ignore_path.is_file() { + let rv = env.render_str(ignore_template, context); + match rv { + Err(e) => { + return Err(anyhow!("failed to render ignore file template: {}", e)); + } + Ok(rv) => { + fs::write(&vcs_ignore_path, rv) + .path_context(&vcs_ignore_path, "failed to write {vcs_ignore_path}")?; + } + } + } + Ok(()) +} + +#[cfg(test)] +mod test_mercurial { + use super::Mercurial; + use super::VCSInfo; + use std::path::PathBuf; + use std::{env, fs}; + use tempfile::TempDir; + + fn hg_init_temp() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + let hg_init_result = Mercurial::init_dir(&PathBuf::from(temp_dir.path())); + assert!(hg_init_result); + let hg_dir = temp_dir.path().join(".hg"); + assert!(hg_dir.exists()); + + return temp_dir; + } + + #[test] + fn test_is_inside_hg_work_tree_true() { + let temp_dir = hg_init_temp(); + assert!(Mercurial::inside_work_tree(&PathBuf::from( + temp_dir.as_ref() + ))); + temp_dir.close().unwrap(); + } + + #[test] + fn test_is_inside_hg_work_tree_false() { + let temp_dir = TempDir::new().unwrap(); + assert!(!Mercurial::inside_work_tree(&PathBuf::from( + temp_dir.as_ref() + ))); + temp_dir.close().unwrap(); + } + + #[test] + fn test_hg_get_author_defaults() { + // case 1: no author set, defaults set + + // disable normal mercurial config search path + env::set_var("HGRCPATH", ""); + let temp_dir = hg_init_temp(); + let (name, email) = Mercurial::get_author( + &PathBuf::from(temp_dir.as_ref()), + ( + Some("defaultname".to_string()), + Some("defaultemail".to_string()), + ), + ); + assert_eq!(name, Some("defaultname".to_string())); + assert_eq!(email, Some("defaultemail".to_string())); + } + + #[test] + fn test_hg_get_author_hgrc() { + // case 2: author set, defaults set + + let temp_dir = hg_init_temp(); + let hg_user = "hg_username"; + let hg_email = "hg_email"; + let hgrc_path = temp_dir.path().join(".hg").join("hgrc"); + + fs::write( + &hgrc_path, + format!("[ui]\nusername = {}\nemail = {}", hg_user, hg_email).as_str(), + ) + .unwrap(); + let (name, email) = Mercurial::get_author( + &PathBuf::from(temp_dir.as_ref()), + ( + Some("defaultname".to_string()), + Some("defaultemail".to_string()), + ), + ); + assert_eq!(name, Some(hg_user.to_string())); + assert_eq!(email, Some(hg_email.to_string())); + } +} + +#[cfg(test)] +mod test_git { + use super::{Git, VCSInfo}; + use std::path::PathBuf; + use std::{env, fs}; + use tempfile::TempDir; + + fn git_init_temp() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + let hg_init_result = Git::init_dir(&PathBuf::from(temp_dir.path())); + assert!(hg_init_result); + let hg_dir = temp_dir.path().join(".git"); + assert!(hg_dir.exists()); + + return temp_dir; + } + + #[test] + fn test_git_is_inside_work_tree_true() { + let temp_dir = git_init_temp(); + assert!(Git::inside_work_tree(&PathBuf::from(temp_dir.as_ref()))); + temp_dir.close().unwrap(); + } + + #[test] + fn test_git_is_inside_work_tree_false() { + let temp_dir = TempDir::new().unwrap(); + assert!(!Git::inside_work_tree(&PathBuf::from(temp_dir.as_ref()))); + temp_dir.close().unwrap(); + } + + #[test] + fn test_git_get_author_defaults() { + // case 1: no author set, defaults set + // set env to disable global git config + env::set_var("GIT_CONFIG_GLOBAL", "/dev/null"); + let temp_dir = git_init_temp(); + let (name, email) = Git::get_author( + &PathBuf::from(temp_dir.as_ref()), + ( + Some("defaultname".to_string()), + Some("defaultemail".to_string()), + ), + ); + assert_eq!(name, Some("defaultname".to_string())); + assert_eq!(email, Some("defaultemail".to_string())); + } + + #[test] + fn test_git_get_author_git() { + // case 2: author set, defaults set + + let temp_dir = git_init_temp(); + let git_user = "git_username"; + let git_email = "git_email"; + let gitconfig_path = temp_dir.path().join(".git").join("config"); + + fs::write( + &gitconfig_path, + format!("[user]\n name = {}\n email = {}", git_user, git_email).as_str(), + ) + .unwrap(); + let (name, email) = Git::get_author( + &PathBuf::from(temp_dir.as_ref()), + ( + Some("defaultname".to_string()), + Some("defaultemail".to_string()), + ), + ); + assert_eq!(name, Some(git_user.to_string())); + assert_eq!(email, Some(git_email.to_string())); + } +}