Skip to content

Commit

Permalink
Initialize a Git repository in uv init (#5476)
Browse files Browse the repository at this point in the history
## Summary

Similiar to `cargo init --vcs <VCS>`, this PR adds the `--vcs <VCS>`
flag for `uv init`, allowing to create a version control system during
initialization. By default, `uv init` will create a Git repository if
the `--vcs` flag is not provided. Use `--vcs none` to disable this
feature.

Currently, only Git is supported. While Cargo also supports hg, pijul,
and fossil, this initial PR only includes Git. We can add more later if
there are any user requests.

---------

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
  • Loading branch information
j178 and charliermarsh authored Sep 26, 2024
1 parent 4ba0e56 commit 0c801f8
Show file tree
Hide file tree
Showing 10 changed files with 346 additions and 2 deletions.
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use url::Url;
use uv_cache::CacheArgs;
use uv_configuration::{
ConfigSettingEntry, ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier,
TargetTriple, TrustedHost, TrustedPublishing,
TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem,
};
use uv_normalize::{ExtraName, PackageName};
use uv_python::{PythonDownloads, PythonPreference, PythonVersion};
Expand Down Expand Up @@ -2373,6 +2373,13 @@ pub struct InitArgs {
#[arg(long, alias="script", conflicts_with_all=["app", "lib", "package"])]
pub r#script: bool,

/// Initialize a version control system for the project.
///
/// By default, uv will initialize a Git repository (`git`). Use `--vcs none` to explicitly
/// avoid initializing a version control system.
#[arg(long, value_enum, conflicts_with = "script")]
pub vcs: Option<VersionControlSystem>,

/// Do not create a `README.md` file.
#[arg(long)]
pub no_readme: bool,
Expand Down
2 changes: 2 additions & 0 deletions crates/uv-configuration/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ uv-normalize = { workspace = true }

clap = { workspace = true, features = ["derive"], optional = true }
either = { workspace = true }
fs-err = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
which = { workspace = true }

[dev-dependencies]
anyhow = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions crates/uv-configuration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub use sources::*;
pub use target_triple::*;
pub use trusted_host::*;
pub use trusted_publishing::*;
pub use vcs::*;

mod authentication;
mod build_options;
Expand All @@ -37,3 +38,4 @@ mod sources;
mod target_triple;
mod trusted_host;
mod trusted_publishing;
mod vcs;
120 changes: 120 additions & 0 deletions crates/uv-configuration/src/vcs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use serde::Deserialize;
use tracing::debug;

#[derive(Debug, thiserror::Error)]
pub enum VersionControlError {
#[error("Attempted to initialize a Git repository, but `git` was not found in PATH")]
GitNotInstalled,
#[error("Failed to initialize Git repository at `{0}`\nstdout: {1}\nstderr: {2}")]
GitInit(PathBuf, String, String),
#[error("`git` command failed")]
GitCommand(#[source] std::io::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
}

/// The version control system to use.
#[derive(Clone, Copy, Debug, PartialEq, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum VersionControlSystem {
/// Use Git for version control.
#[default]
Git,
/// Do not use any version control system.
None,
}

impl VersionControlSystem {
/// Initializes the VCS system based on the provided path.
pub fn init(&self, path: &Path) -> Result<(), VersionControlError> {
match self {
Self::Git => {
let Ok(git) = which::which("git") else {
return Err(VersionControlError::GitNotInstalled);
};

if path.join(".git").try_exists()? {
debug!("Git repository already exists at: `{}`", path.display());
} else {
let output = Command::new(git)
.arg("init")
.current_dir(path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(VersionControlError::GitCommand)?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VersionControlError::GitInit(
path.to_path_buf(),
stdout.to_string(),
stderr.to_string(),
));
}
}

// Create the `.gitignore`, if it doesn't exist.
match fs_err::OpenOptions::new()
.write(true)
.create_new(true)
.open(path.join(".gitignore"))
{
Ok(mut file) => file.write_all(GITIGNORE.as_bytes())?,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => (),
Err(err) => return Err(err.into()),
}

Ok(())
}
Self::None => Ok(()),
}
}

/// Detects the VCS system based on the provided path.
pub fn detect(path: &Path) -> Option<Self> {
// Determine whether the path is inside a Git work tree.
if which::which("git").is_ok_and(|git| {
Command::new(git)
.arg("rev-parse")
.arg("--is-inside-work-tree")
.current_dir(path)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}) {
return Some(Self::Git);
}

None
}
}

impl std::fmt::Display for VersionControlSystem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Git => write!(f, "git"),
Self::None => write!(f, "none"),
}
}
}

const GITIGNORE: &str = "# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
";
67 changes: 67 additions & 0 deletions crates/uv/src/commands/project/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};
use owo_colors::OwoColorize;

use pep440_rs::Version;
use pep508_rs::PackageName;
use tracing::{debug, warn};
use uv_cache::Cache;
use uv_client::{BaseClientBuilder, Connectivity};
use uv_configuration::{VersionControlError, VersionControlSystem};
use uv_fs::{Simplified, CWD};
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
Expand All @@ -32,6 +34,7 @@ pub(crate) async fn init(
name: Option<PackageName>,
package: bool,
init_kind: InitKind,
vcs: Option<VersionControlSystem>,
no_readme: bool,
no_pin_python: bool,
python: Option<String>,
Expand Down Expand Up @@ -106,6 +109,7 @@ pub(crate) async fn init(
&name,
package,
project_kind,
vcs,
no_readme,
no_pin_python,
python,
Expand Down Expand Up @@ -231,6 +235,7 @@ async fn init_project(
name: &PackageName,
package: bool,
project_kind: InitProjectKind,
vcs: Option<VersionControlSystem>,
no_readme: bool,
no_pin_python: bool,
python: Option<String>,
Expand Down Expand Up @@ -455,6 +460,7 @@ async fn init_project(
path,
&requires_python,
python_request.as_ref(),
vcs,
no_readme,
package,
)
Expand Down Expand Up @@ -543,6 +549,7 @@ impl InitProjectKind {
path: &Path,
requires_python: &RequiresPython,
python_request: Option<&PythonRequest>,
vcs: Option<VersionControlSystem>,
no_readme: bool,
package: bool,
) -> Result<()> {
Expand All @@ -553,6 +560,7 @@ impl InitProjectKind {
path,
requires_python,
python_request,
vcs,
no_readme,
package,
)
Expand All @@ -564,6 +572,7 @@ impl InitProjectKind {
path,
requires_python,
python_request,
vcs,
no_readme,
package,
)
Expand All @@ -572,12 +581,14 @@ impl InitProjectKind {
}
}

/// Initialize a Python application at the target path.
async fn init_application(
self,
name: &PackageName,
path: &Path,
requires_python: &RequiresPython,
python_request: Option<&PythonRequest>,
vcs: Option<VersionControlSystem>,
no_readme: bool,
package: bool,
) -> Result<()> {
Expand Down Expand Up @@ -645,15 +656,20 @@ impl InitProjectKind {
}
}

// Initialize the version control system.
init_vcs(path, vcs)?;

Ok(())
}

/// Initialize a library project at the target path.
async fn init_library(
self,
name: &PackageName,
path: &Path,
requires_python: &RequiresPython,
python_request: Option<&PythonRequest>,
vcs: Option<VersionControlSystem>,
no_readme: bool,
package: bool,
) -> Result<()> {
Expand Down Expand Up @@ -705,6 +721,9 @@ impl InitProjectKind {
}
}

// Initialize the version control system.
init_vcs(path, vcs)?;

Ok(())
}
}
Expand Down Expand Up @@ -745,3 +764,51 @@ fn pyproject_project_scripts(package: &PackageName, executable_name: &str, targe
{executable_name} = "{module_name}:{target}"
"#}
}

/// Initialize the version control system at the given path.
fn init_vcs(path: &Path, vcs: Option<VersionControlSystem>) -> Result<()> {
// Detect any existing version control system.
let existing = VersionControlSystem::detect(path);

let implicit = vcs.is_none();

let vcs = match (vcs, existing) {
// If no version control system was specified, and none was detected, default to Git.
(None, None) => VersionControlSystem::default(),
// If no version control system was specified, but a VCS was detected, leave it as-is.
(None, Some(existing)) => {
debug!("Detected existing version control system: {existing}");
VersionControlSystem::None
}
// If the user provides an explicit `--vcs none`,
(Some(VersionControlSystem::None), _) => VersionControlSystem::None,
// If a version control system was specified, use it.
(Some(vcs), None) => vcs,
// If a version control system was specified, but a VCS was detected...
(Some(vcs), Some(existing)) => {
// If they differ, raise an error.
if vcs != existing {
anyhow::bail!("The project is already in a version control system (`{existing}`); cannot initialize with `--vcs {vcs}`");
}

// Otherwise, ignore the specified VCS, since it's already in use.
VersionControlSystem::None
}
};

// Attempt to initialize the VCS.
match vcs.init(path) {
Ok(()) => (),
// If the VCS isn't installed, only raise an error if a VCS was explicitly specified.
Err(err @ VersionControlError::GitNotInstalled) => {
if implicit {
debug!("Failed to initialize version control: {err}");
} else {
return Err(err.into());
}
}
Err(err) => return Err(err.into()),
}

Ok(())
}
1 change: 1 addition & 0 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,7 @@ async fn run_project(
args.name,
args.package,
args.kind,
args.vcs,
args.no_readme,
args.no_pin_python,
args.python,
Expand Down
5 changes: 4 additions & 1 deletion crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use uv_configuration::{
BuildOptions, Concurrency, ConfigSettings, DevMode, EditableMode, ExportFormat,
ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions, KeyringProviderType,
NoBinary, NoBuild, PreviewMode, Reinstall, SourceStrategy, TargetTriple, TrustedHost,
TrustedPublishing, Upgrade,
TrustedPublishing, Upgrade, VersionControlSystem,
};
use uv_normalize::PackageName;
use uv_python::{Prefix, PythonDownloads, PythonPreference, PythonVersion, Target};
Expand Down Expand Up @@ -162,6 +162,7 @@ pub(crate) struct InitSettings {
pub(crate) name: Option<PackageName>,
pub(crate) package: bool,
pub(crate) kind: InitKind,
pub(crate) vcs: Option<VersionControlSystem>,
pub(crate) no_readme: bool,
pub(crate) no_pin_python: bool,
pub(crate) no_workspace: bool,
Expand All @@ -181,6 +182,7 @@ impl InitSettings {
app,
lib,
script,
vcs,
no_readme,
no_pin_python,
no_workspace,
Expand All @@ -202,6 +204,7 @@ impl InitSettings {
name,
package,
kind,
vcs,
no_readme,
no_pin_python,
no_workspace,
Expand Down
Loading

0 comments on commit 0c801f8

Please sign in to comment.