Skip to content

Commit db8fddd

Browse files
feat: allow incremental progress
1 parent 06344f2 commit db8fddd

File tree

2 files changed

+109
-27
lines changed

2 files changed

+109
-27
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ cargo install create-rust-github-repo
4545
## Usage
4646

4747
```
48+
`create-rust-github-repo` is a CLI program that creates a new repository on GitHub, clones it locally, initializes a Rust project, copies the configs from a pre-existing directory.
49+
4850
Usage: create-rust-github-repo [OPTIONS] --name <NAME>
4951
5052
Options:
@@ -54,6 +56,8 @@ Options:
5456
Target directory for cloning the repository (must include the repo name) (defaults to "{current_dir}/{repo_name}") (see also: --workspace)
5557
-w, --workspace <WORKSPACE>
5658
Parent of the target directory for cloning the repository (must NOT include the repo name). If this option is specified, then the repo is cloned to "{workspace}/{repo_name}". The --dir option overrides this option
59+
--shell <SHELL>
60+
Shell to use for executing commands [default: /bin/sh]
5761
-v, --visibility <VISIBILITY>
5862
Repository visibility [default: private] [possible values: public, private, internal]
5963
-c, --copy-configs-from <COPY_CONFIGS_FROM>
@@ -62,6 +66,8 @@ Options:
6266
Message for git commit [default: "Add configs"]
6367
--extra-configs <EXTRA_CONFIGS>
6468
Extra config file paths (relative to `source` directory)
69+
--repo-exists-cmd <REPO_EXISTS_CMD>
70+
Shell command to check if repo exists (supports substitutions - see help below) [default: "gh repo view --json nameWithOwner \"{{name}}\" | grep \"{{name}}\""]
6571
--gh-repo-create-args <GH_REPO_CREATE_ARGS>
6672
Forwarded arguments for `gh repo create`
6773
--gh-repo-clone-args <GH_REPO_CLONE_ARGS>
@@ -76,6 +82,11 @@ Options:
7682
Forwarded arguments for `git push`
7783
-h, --help
7884
Print help
85+
-V, --version
86+
Print version
87+
88+
All command arg options support the following substitutions:
89+
* {{name}} - substituted with --name arg
7990
```
8091

8192
## Additional binaries

src/lib.rs

Lines changed: 98 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashMap;
12
use std::env::current_dir;
23
use std::ffi::OsStr;
34
use std::io;
@@ -27,6 +28,7 @@ impl RepoVisibility {
2728
}
2829

2930
#[derive(Parser, Setters, Debug)]
31+
#[command(version, about, author, after_help = "All command arg options support the following substitutions:\n* {{name}} - substituted with --name arg\n")]
3032
#[setters(into)]
3133
pub struct CreateRustGithubRepo {
3234
#[arg(long, short = 'n', help = "Repository name")]
@@ -38,6 +40,9 @@ pub struct CreateRustGithubRepo {
3840
#[arg(long, short, help = "Parent of the target directory for cloning the repository (must NOT include the repo name). If this option is specified, then the repo is cloned to \"{workspace}/{repo_name}\". The --dir option overrides this option", value_parser = value_parser!(PathBuf))]
3941
workspace: Option<PathBuf>,
4042

43+
#[arg(long, help = "Shell to use for executing commands", default_value = "/bin/sh")]
44+
shell: String,
45+
4146
#[arg(long, short = 'v', help = "Repository visibility", value_enum, default_value_t)]
4247
visibility: RepoVisibility,
4348

@@ -50,6 +55,9 @@ pub struct CreateRustGithubRepo {
5055
#[arg(long, help = "Extra config file paths (relative to `source` directory)", value_delimiter = ',')]
5156
extra_configs: Vec<String>,
5257

58+
#[arg(long, help = "Shell command to check if repo exists (supports substitutions - see help below)", default_value = "gh repo view --json nameWithOwner \"{{name}}\" | grep \"{{name}}\"")]
59+
repo_exists_cmd: String,
60+
5361
#[arg(long, help = "Forwarded arguments for `gh repo create`", value_delimiter = ' ')]
5462
gh_repo_create_args: Vec<String>,
5563

@@ -77,60 +85,117 @@ impl CreateRustGithubRepo {
7785
.or_else(|| self.workspace.map(|workspace| workspace.join(&self.name)))
7886
.unwrap_or(current_dir.join(&self.name));
7987

80-
// Create a GitHub repo
81-
exec(
82-
"gh",
83-
[
84-
"repo",
85-
"create",
86-
&self.name,
87-
self.visibility.to_gh_create_repo_flag(),
88-
],
89-
self.gh_repo_create_args.into_iter(),
90-
&current_dir,
91-
)
92-
.context("Failed to create GitHub repository")?;
93-
94-
// Clone the repo
95-
exec("gh", ["repo", "clone", &self.name, dir.to_str().unwrap()], self.gh_repo_clone_args.into_iter(), &current_dir).context("Failed to clone repository")?;
96-
97-
// Run cargo init
98-
exec("cargo", ["init"], self.cargo_init_args.into_iter(), &dir).context("Failed to initialize Cargo project")?;
88+
let substitutions = HashMap::<&'static str, &str>::from([("{{name}}", self.name.as_str())]);
89+
90+
let repo_exists = success(&self.shell, ["-c"], [self.repo_exists_cmd], &current_dir, &substitutions)?;
91+
92+
if !repo_exists {
93+
// Create a GitHub repo
94+
exec(
95+
"gh",
96+
[
97+
"repo",
98+
"create",
99+
&self.name,
100+
self.visibility.to_gh_create_repo_flag(),
101+
],
102+
self.gh_repo_create_args,
103+
&current_dir,
104+
&substitutions,
105+
)
106+
.context("Failed to create GitHub repository")?;
107+
}
108+
109+
if !dir.exists() {
110+
// Clone the repo
111+
exec("gh", ["repo", "clone", &self.name, dir.to_str().unwrap()], self.gh_repo_clone_args.into_iter(), &current_dir, &substitutions).context("Failed to clone repository")?;
112+
} else {
113+
println!("Directory \"{}\" exists, skipping clone command", dir.display())
114+
}
115+
116+
let cargo_toml = dir.join("Cargo.toml");
117+
118+
if !cargo_toml.exists() {
119+
// Run cargo init
120+
exec("cargo", ["init"], self.cargo_init_args.into_iter(), &dir, &substitutions).context("Failed to initialize Cargo project")?;
121+
} else {
122+
println!("Cargo.toml exists in \"{}\", skipping `cargo init` command", dir.display())
123+
}
99124

100125
if let Some(copy_configs_from) = self.copy_configs_from {
101126
let mut configs: Vec<String> = vec![];
102127
configs.extend(CONFIGS.iter().copied().map(ToOwned::to_owned));
103128
configs.extend(self.extra_configs);
104129
// Copy config files
105-
copy_configs(&copy_configs_from, &dir, configs).context("Failed to copy configuration files")?;
130+
copy_configs_if_not_exists(&copy_configs_from, &dir, configs).context("Failed to copy configuration files")?;
106131
}
107132

108133
// Run cargo build
109-
exec("cargo", ["build"], self.cargo_build_args.into_iter(), &dir).context("Failed to build Cargo project")?;
134+
exec("cargo", ["build"], self.cargo_build_args.into_iter(), &dir, &substitutions).context("Failed to build Cargo project")?;
110135

111136
// Git commit
112-
exec("git", ["add", "."], Vec::<String>::new().into_iter(), &dir).context("Failed to stage files for commit")?;
137+
exec("git", ["add", "."], Vec::<String>::new().into_iter(), &dir, &substitutions).context("Failed to stage files for commit")?;
113138

114-
exec("git", ["commit", "-m", &self.git_commit_message], self.git_commit_args.into_iter(), &dir).context("Failed to commit changes")?;
139+
exec("git", ["commit", "-m", &self.git_commit_message], self.git_commit_args.into_iter(), &dir, &substitutions).context("Failed to commit changes")?;
115140

116141
// Git push
117-
exec("git", ["push"], self.git_push_args.into_iter(), &dir).context("Failed to push changes")?;
142+
exec("git", ["push"], self.git_push_args.into_iter(), &dir, &substitutions).context("Failed to push changes")?;
118143

119144
Ok(())
120145
}
121146
}
122147

123-
pub fn exec(cmd: impl AsRef<OsStr>, args: impl IntoIterator<Item = impl AsRef<OsStr>>, extra_args: impl IntoIterator<Item = impl AsRef<OsStr>>, current_dir: impl AsRef<Path>) -> io::Result<ExitStatus> {
148+
pub fn replace_args(args: impl IntoIterator<Item = String>, substitutions: &HashMap<&str, &str>) -> Vec<String> {
149+
args.into_iter()
150+
.map(|arg| replace_all(arg, substitutions))
151+
.collect()
152+
}
153+
154+
pub fn replace_all(mut input: String, substitutions: &HashMap<&str, &str>) -> String {
155+
for (key, value) in substitutions {
156+
input = input.replace(key, value);
157+
}
158+
input
159+
}
160+
161+
pub fn exec(cmd: impl AsRef<OsStr>, args: impl IntoIterator<Item = impl AsRef<OsStr>>, extra_args: impl IntoIterator<Item = String>, current_dir: impl AsRef<Path>, substitutions: &HashMap<&str, &str>) -> io::Result<ExitStatus> {
162+
let replacements = replace_args(extra_args, substitutions);
163+
let extra_args = replacements.iter().map(AsRef::<OsStr>::as_ref);
164+
exec_raw(cmd, args, extra_args, current_dir)
165+
}
166+
167+
pub fn success(cmd: impl AsRef<OsStr>, args: impl IntoIterator<Item = impl AsRef<OsStr>>, extra_args: impl IntoIterator<Item = String>, current_dir: impl AsRef<Path>, substitutions: &HashMap<&str, &str>) -> io::Result<bool> {
168+
let replacements = replace_args(extra_args, substitutions);
169+
let extra_args = replacements.iter().map(AsRef::<OsStr>::as_ref);
170+
success_raw(cmd, args, extra_args, current_dir)
171+
}
172+
173+
pub fn exec_raw(cmd: impl AsRef<OsStr>, args: impl IntoIterator<Item = impl AsRef<OsStr>>, extra_args: impl IntoIterator<Item = impl AsRef<OsStr>>, current_dir: impl AsRef<Path>) -> io::Result<ExitStatus> {
174+
get_status_raw(cmd, args, extra_args, current_dir).and_then(check_status)
175+
}
176+
177+
pub fn success_raw(cmd: impl AsRef<OsStr>, args: impl IntoIterator<Item = impl AsRef<OsStr>>, extra_args: impl IntoIterator<Item = impl AsRef<OsStr>>, current_dir: impl AsRef<Path>) -> io::Result<bool> {
178+
get_status_raw(cmd, args, extra_args, current_dir).map(|status| status.success())
179+
}
180+
181+
pub fn get_status_raw(cmd: impl AsRef<OsStr>, args: impl IntoIterator<Item = impl AsRef<OsStr>>, extra_args: impl IntoIterator<Item = impl AsRef<OsStr>>, current_dir: impl AsRef<Path>) -> io::Result<ExitStatus> {
124182
Command::new(cmd)
125183
.args(args)
126184
.args(extra_args)
127185
.current_dir(current_dir)
128186
.spawn()?
129187
.wait()
130-
.and_then(|status| if status.success() { Ok(status) } else { Err(io::Error::new(io::ErrorKind::Other, format!("Process exited with with status {}", status))) })
131188
}
132189

133-
pub fn copy_configs<P: Clone + AsRef<Path>>(source: &Path, target: &Path, configs: impl IntoIterator<Item = P>) -> io::Result<()> {
190+
pub fn check_status(status: ExitStatus) -> io::Result<ExitStatus> {
191+
if status.success() {
192+
Ok(status)
193+
} else {
194+
Err(io::Error::new(io::ErrorKind::Other, format!("Process exited with with status {}", status)))
195+
}
196+
}
197+
198+
pub fn copy_configs_if_not_exists<P: Clone + AsRef<Path>>(source: &Path, target: &Path, configs: impl IntoIterator<Item = P>) -> io::Result<()> {
134199
for config in configs {
135200
let source_path = source.join(config.clone());
136201
let target_path = target.join(config);
@@ -154,3 +219,9 @@ pub const CONFIGS: &[&str] = &[
154219
"lefthook.json",
155220
".lefthook.json",
156221
];
222+
223+
#[test]
224+
fn verify_cli() {
225+
use clap::CommandFactory;
226+
CreateRustGithubRepo::command().debug_assert();
227+
}

0 commit comments

Comments
 (0)