Repository: cargo-generate/cargo-generate (rust)

Issue:
cargo-generate doesn't work with repos in 'detached HEAD' status
Repro:
1. Create `dummy` directory
2. `git init`
3. Add a `dummy` file
4. `git add dummy`
5. `git commit -m "Initial commit"`
6. Edit `dummy`
7. `git add -u`
8. `git commit -m "Commit 2"`
9. `git checkout HEAD^`
10. `cargo generate --name dummy --git .\`

```
cargo generate --name dummy --git .\
Creating project called `dummy`...
Error:  Git Error: A repo has no Head
```

This is a common state to be in during PR CI scenarios.

Treating a checked out template as a folder, as opposed to a repo, would help a lot (https://github.com/cargo-generate/cargo-generate/issues/387) but not sure we'll fall into this check https://github.com/cargo-generate/cargo-generate/commit/d2211d28e8ad4d51856323e9ba7e7879dabae163. Ideally, a --directory or --folder parameter would be added instead of this fall-through behavior.

See also:
https://github.com/actions/checkout/issues/6


Pull request description:
Introduce `--path` flag
Closes #406
Relates to #47 

Introduce `--path <path>` to use a local folder as the template.

This solves the issue of being unable to use a local 'dirty' git repo as the template. This would e.g. be the case during template development if the developer doesn't commit between each retry (which seems like a very likely scenario).


Patch:
diff --git a/src/app_config.rs b/src/app_config.rs
index 55020cd..39cdc05 100644
--- a/src/app_config.rs
+++ b/src/app_config.rs
@@ -22,6 +22,7 @@ pub(crate) struct FavoriteConfig {
     pub git: Option<String>,
     pub branch: Option<String>,
     pub subfolder: Option<String>,
+    pub path: Option<PathBuf>,
 }
 
 impl Default for AppConfig {
diff --git a/src/args.rs b/src/args.rs
new file mode 100644
index 0000000..570aaf1
--- /dev/null
+++ b/src/args.rs
@@ -0,0 +1,134 @@
+use std::{
+    path::{Path, PathBuf},
+    str::FromStr,
+};
+
+use anyhow::{anyhow, Result};
+use structopt::StructOpt;
+
+use crate::git;
+
+#[derive(StructOpt)]
+#[structopt(bin_name = "cargo")]
+pub enum Cli {
+    #[structopt(name = "generate", visible_alias = "gen")]
+    Generate(Args),
+}
+
+#[derive(Debug, StructOpt)]
+pub struct Args {
+    /// List defined favorite templates from the config
+    #[structopt(long)]
+    pub list_favorites: bool,
+
+    /// Generate a favorite template as defined in the config. In case the favorite is undefined,
+    /// use in place of the `--git` option, otherwise specifies the subfolder
+    #[structopt(name = "favorite|git|subfolder")]
+    pub favorite: Option<String>,
+
+    /// Specifies a subfolder within the template repository to be used as the actual template.
+    #[structopt()]
+    pub subfolder: Option<String>,
+
+    /// Git repository to clone template from. Can be a URL (like
+    /// `https://github.com/rust-cli/cli-template`), a path (relative or absolute), or an
+    /// `owner/repo` abbreviated GitHub URL (like `rust-cli/cli-template`).
+    ///
+    /// Note that cargo generate will first attempt to interpret the `owner/repo` form as a
+    /// relative path and only try a GitHub URL if the local path doesn't exist.
+    #[structopt(short, long, conflicts_with = "subfolder")]
+    pub git: Option<String>,
+
+    /// Local path to copy the template from. Can not be specified together with --git.
+    #[structopt(
+        short,
+        long,
+        conflicts_with = "git",
+        conflicts_with = "favorite",
+        conflicts_with = "subfolder"
+    )]
+    pub path: Option<PathBuf>,
+
+    /// Branch to use when installing from git
+    #[structopt(short, long)]
+    pub branch: Option<String>,
+
+    /// Directory to create / project name; if the name isn't in kebab-case, it will be converted
+    /// to kebab-case unless `--force` is given.
+    #[structopt(long, short)]
+    pub name: Option<String>,
+
+    /// Don't convert the project name to kebab-case before creating the directory.
+    /// Note that cargo generate won't overwrite an existing directory, even if `--force` is given.
+    #[structopt(long, short)]
+    pub force: bool,
+
+    /// Enables more verbose output.
+    #[structopt(long, short)]
+    pub verbose: bool,
+
+    /// Pass template values through a file
+    /// Values should be in the format `key=value`, one per line
+    #[structopt(long)]
+    pub template_values_file: Option<String>,
+
+    /// If silent mode is set all variables will be
+    /// extracted from the template_values_file.
+    /// If a value is missing the project generation will fail
+    #[structopt(long, short, requires("name"))]
+    pub silent: bool,
+
+    /// Use specific configuration file. Defaults to $CARGO_HOME/cargo-generate or $HOME/.cargo/cargo-generate
+    #[structopt(short, long, parse(from_os_str))]
+    pub config: Option<PathBuf>,
+
+    /// Specify the VCS used to initialize the generated template.
+    #[structopt(long, default_value = "git")]
+    pub vcs: Vcs,
+
+    /// Populates a template variable `crate_type` with value `"lib"`
+    #[structopt(long, conflicts_with = "bin")]
+    pub lib: bool,
+
+    /// Populates a template variable `crate_type` with value `"bin"`
+    #[structopt(long, conflicts_with = "lib")]
+    pub bin: bool,
+
+    /// Use a different ssh identity
+    #[structopt(short = "i", long = "identity", parse(from_os_str))]
+    pub ssh_identity: Option<PathBuf>,
+
+    /// Define a value for use during template expansion
+    #[structopt(long, short, number_of_values = 1)]
+    pub define: Vec<String>,
+}
+
+#[derive(Debug, StructOpt, Clone, Copy)]
+pub enum Vcs {
+    None,
+    Git,
+}
+
+impl FromStr for Vcs {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s.to_uppercase().as_str() {
+            "NONE" => Ok(Vcs::None),
+            "GIT" => Ok(Vcs::Git),
+            _ => Err(anyhow!("Must be one of 'git' or 'none'")),
+        }
+    }
+}
+
+impl Vcs {
+    pub fn initialize(&self, project_dir: &Path, branch: String) -> Result<()> {
+        match self {
+            Vcs::None => {}
+            Vcs::Git => {
+                git::init(project_dir, &branch)?;
+            }
+        };
+        Ok(())
+    }
+}
diff --git a/src/favorites.rs b/src/favorites.rs
index e636280..6dceec0 100644
--- a/src/favorites.rs
+++ b/src/favorites.rs
@@ -48,12 +48,16 @@ pub(crate) fn resolve_favorite_args(app_config: &AppConfig, args: &mut Args) ->
         return Ok(());
     }
 
+    if args.path.is_some() {
+        return Ok(());
+    }
+
     let favorite_name = args
         .favorite
         .as_ref()
         .ok_or_else(|| anyhow!("Please specify either --git option, or a predefined favorite"))?;
 
-    let (git, branch, subfolder) = app_config
+    let (git, branch, subfolder, path) = app_config
         .favorites
         .get(favorite_name.as_str())
         .map_or_else(
@@ -66,6 +70,7 @@ pub(crate) fn resolve_favorite_args(app_config: &AppConfig, args: &mut Args) ->
                     Some(favorite_name.clone()),
                     args.branch.as_ref().cloned(),
                     args.subfolder.clone(),
+                    None,
                 )
             },
             |f| {
@@ -76,6 +81,7 @@ pub(crate) fn resolve_favorite_args(app_config: &AppConfig, args: &mut Args) ->
                         .as_ref()
                         .or_else(|| f.subfolder.as_ref())
                         .cloned(),
+                    f.path.clone(),
                 )
             },
         );
@@ -83,6 +89,7 @@ pub(crate) fn resolve_favorite_args(app_config: &AppConfig, args: &mut Args) ->
     args.git = git;
     args.branch = branch;
     args.subfolder = subfolder;
+    args.path = path;
 
     Ok(())
 }
diff --git a/src/lib.rs b/src/lib.rs
index 7c2a269..dd04c5e 100755
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -79,6 +79,7 @@
 //! `os-arch` and `authors` also can't be overriden and are derived from the environment.
 
 mod app_config;
+mod args;
 mod config;
 mod emoji;
 mod favorites;
@@ -93,6 +94,8 @@ mod project_variables;
 mod template;
 mod template_variables;
 
+pub use args::*;
+
 use anyhow::{anyhow, bail, Context, Result};
 use config::{Config, CONFIG_FILE_NAME};
 use console::style;
@@ -102,9 +105,8 @@ use std::{
     collections::HashMap,
     env, fs,
     path::{Path, PathBuf},
-    str::FromStr,
 };
-use structopt::StructOpt;
+
 use tempfile::TempDir;
 
 use crate::{
@@ -113,121 +115,6 @@ use crate::{
 };
 use crate::{git::GitConfig, template_variables::resolve_template_values};
 
-#[derive(StructOpt)]
-#[structopt(bin_name = "cargo")]
-pub enum Cli {
-    #[structopt(name = "generate", visible_alias = "gen")]
-    Generate(Args),
-}
-
-#[derive(Debug, StructOpt)]
-pub struct Args {
-    /// List defined favorite templates from the config
-    #[structopt(long)]
-    pub list_favorites: bool,
-
-    /// Generate a favorite template as defined in the config. In case the favorite is undefined,
-    /// use in place of the `--git` option, otherwise specifies the subfolder
-    #[structopt(name = "favorite|git|subfolder")]
-    pub favorite: Option<String>,
-
-    /// Specifies a subfolder within the template repository to be used as the actual template.
-    #[structopt()]
-    pub subfolder: Option<String>,
-
-    /// Git repository to clone template from. Can be a URL (like
-    /// `https://github.com/rust-cli/cli-template`), a path (relative or absolute), or an
-    /// `owner/repo` abbreviated GitHub URL (like `rust-cli/cli-template`).
-    ///
-    /// Note that cargo generate will first attempt to interpret the `owner/repo` form as a
-    /// relative path and only try a GitHub URL if the local path doesn't exist.
-    #[structopt(short, long, conflicts_with = "subfolder")]
-    pub git: Option<String>,
-
-    /// Branch to use when installing from git
-    #[structopt(short, long)]
-    pub branch: Option<String>,
-
-    /// Directory to create / project name; if the name isn't in kebab-case, it will be converted
-    /// to kebab-case unless `--force` is given.
-    #[structopt(long, short)]
-    pub name: Option<String>,
-
-    /// Don't convert the project name to kebab-case before creating the directory.
-    /// Note that cargo generate won't overwrite an existing directory, even if `--force` is given.
-    #[structopt(long, short)]
-    pub force: bool,
-
-    /// Enables more verbose output.
-    #[structopt(long, short)]
-    pub verbose: bool,
-
-    /// Pass template values through a file
-    /// Values should be in the format `key=value`, one per line
-    #[structopt(long)]
-    pub template_values_file: Option<String>,
-
-    /// If silent mode is set all variables will be
-    /// extracted from the template_values_file.
-    /// If a value is missing the project generation will fail
-    #[structopt(long, short, requires("name"))]
-    pub silent: bool,
-
-    /// Use specific configuration file. Defaults to $CARGO_HOME/cargo-generate or $HOME/.cargo/cargo-generate
-    #[structopt(short, long, parse(from_os_str))]
-    pub config: Option<PathBuf>,
-
-    /// Specify the VCS used to initialize the generated template.
-    #[structopt(long, default_value = "git")]
-    pub vcs: Vcs,
-
-    /// Populates a template variable `crate_type` with value `"lib"`
-    #[structopt(long, conflicts_with = "bin")]
-    pub lib: bool,
-
-    /// Populates a template variable `crate_type` with value `"bin"`
-    #[structopt(long, conflicts_with = "lib")]
-    pub bin: bool,
-
-    /// Use a different ssh identity
-    #[structopt(short = "i", long = "identity", parse(from_os_str))]
-    pub ssh_identity: Option<PathBuf>,
-
-    /// Define a value for use during template expansion
-    #[structopt(long, short, number_of_values = 1)]
-    pub define: Vec<String>,
-}
-
-#[derive(Debug, StructOpt, Clone, Copy)]
-pub enum Vcs {
-    None,
-    Git,
-}
-
-impl FromStr for Vcs {
-    type Err = anyhow::Error;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        match s.to_uppercase().as_str() {
-            "NONE" => Ok(Vcs::None),
-            "GIT" => Ok(Vcs::Git),
-            _ => Err(anyhow!("Must be one of 'git' or 'none'")),
-        }
-    }
-}
-
-impl Vcs {
-    fn initialize(&self, project_dir: &Path, branch: String) -> Result<()> {
-        match self {
-            Vcs::None => {}
-            Vcs::Git => {
-                git::init(project_dir, &branch)?;
-            }
-        };
-        Ok(())
-    }
-}
-
 pub fn generate(mut args: Args) -> Result<()> {
     let path = &app_config_path(&args.config)?;
     let app_config = AppConfig::from_path(path)?;
@@ -241,8 +128,7 @@ pub fn generate(mut args: Args) -> Result<()> {
     let project_name = resolve_project_name(&args)?;
     let project_dir = resolve_project_dir(&project_name, args.force)?;
 
-    let (template_base_dir, branch) = clone_git_template_into_temp(&args)?;
-    let template_folder = resolve_template_dir(&template_base_dir, &args)?;
+    let (template_base_dir, template_folder, branch) = prepare_local_template(&args)?;
 
     let template_config = Config::from_path(
         &locate_template_file(CONFIG_FILE_NAME, &template_base_dir, &args.subfolder).ok(),
@@ -278,6 +164,31 @@ pub fn generate(mut args: Args) -> Result<()> {
     Ok(())
 }
 
+fn prepare_local_template(args: &Args) -> Result<(TempDir, PathBuf, String), anyhow::Error> {
+    let (template_base_dir, template_folder, branch) = match (&args.git, &args.path) {
+        (Some(_), None) => {
+            let (template_base_dir, branch) = clone_git_template_into_temp(args)?;
+            let template_folder = resolve_template_dir(&template_base_dir, args)?;
+            (template_base_dir, template_folder, branch)
+        }
+        (None, Some(_)) => {
+            let template_base_dir = copy_path_template_into_temp(args)?;
+            let branch = args.branch.clone().unwrap_or_else(|| String::from("main"));
+            let template_folder = template_base_dir.path().into();
+            (template_base_dir, template_folder, branch)
+        }
+        _ => bail!(
+            "{} {} {} {} {}",
+            emoji::ERROR,
+            style("Please specify either").bold(),
+            style("--git <repo>").bold().yellow(),
+            style("or").bold(),
+            style("--path <path>").bold().yellow(),
+        ),
+    };
+    Ok((template_base_dir, template_folder, branch))
+}
+
 fn resolve_project_name(args: &Args) -> Result<ProjectName> {
     match args.name {
         Some(ref n) => Ok(ProjectName::new(n)),
@@ -333,13 +244,24 @@ fn resolve_template_dir(template_base_dir: &TempDir, args: &Args) -> Result<Path
     }
 }
 
+fn copy_path_template_into_temp(args: &Args) -> Result<TempDir> {
+    let path_clone_dir = tempfile::tempdir()?;
+    copy_dir_all(
+        args.path
+            .as_ref()
+            .with_context(|| "Missing option git, path or a favorite")?,
+        path_clone_dir.path(),
+    )?;
+    Ok(path_clone_dir)
+}
+
 fn clone_git_template_into_temp(args: &Args) -> Result<(TempDir, String)> {
     let git_clone_dir = tempfile::tempdir()?;
 
     let remote = args
         .git
         .clone()
-        .with_context(|| "Missing option git, or a favorite")?;
+        .with_context(|| "Missing option git, path or a favorite")?;
 
     let git_config = GitConfig::new_abbr(
         remote.into(),


Test patch:
diff --git a/tests/integration/basics.rs b/tests/integration/basics.rs
index 723dffa..260974f 100644
--- a/tests/integration/basics.rs
+++ b/tests/integration/basics.rs
@@ -42,6 +42,37 @@ version = "0.1.0"
     assert_eq!(0, references);
 }
 
+#[test]
+fn it_can_use_a_specified_path() {
+    let template = tmp_dir()
+        .file(
+            "Cargo.toml",
+            r#"[package]
+name = "{{project-name}}"
+description = "A wonderful project"
+version = "0.1.0"
+"#,
+        )
+        .build();
+
+    let dir = tmp_dir().build();
+
+    binary()
+        .arg("generate")
+        .arg("--name")
+        .arg("foobar-project")
+        .arg("--path")
+        .arg(template.path())
+        .current_dir(&dir.path())
+        .assert()
+        .success()
+        .stdout(predicates::str::contains("Done!").from_utf8());
+
+    let repo = git2::Repository::open(&dir.path().join("foobar-project")).unwrap();
+    let references = repo.references().unwrap().count();
+    assert_eq!(0, references);
+}
+
 #[test]
 fn it_removes_git_history() {
     let template = tmp_dir()
diff --git a/tests/integration/library.rs b/tests/integration/library.rs
index c398707..c013114 100644
--- a/tests/integration/library.rs
+++ b/tests/integration/library.rs
@@ -19,6 +19,7 @@ version = "0.1.0"
 
     let args_exposed: Args = Args {
         git: Some(format!("{}", template.path().display())),
+        path: None,
         branch: Some(String::from("main")),
         subfolder: None,
         name: Some(String::from("foobar_project")),