use std::path::Path;
use std::process::Command;
use super::StarterError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitSource {
pub url: String,
pub reference: Option<String>,
}
fn is_full_url(value: &str) -> bool {
value.starts_with("https://")
|| value.starts_with("http://")
|| value.starts_with("git@")
|| value.starts_with("ssh://")
|| value.starts_with("git://")
|| value.starts_with("file://")
}
pub fn resolve(value: &str, ref_override: Option<&str>) -> Result<GitSource, StarterError> {
if is_full_url(value) {
return Ok(GitSource {
url: value.to_owned(),
reference: ref_override.map(str::to_owned),
});
}
let (repo, suffix_ref) = match value.split_once('@') {
Some((repo, r)) => (repo, Some(r)),
None => (value, None),
};
if let (Some(_), Some(_)) = (suffix_ref, ref_override) {
return Err(StarterError::AmbiguousRef);
}
let reference = ref_override
.map(str::to_owned)
.or_else(|| suffix_ref.map(str::to_owned));
let url = expand_shorthand(repo)?;
Ok(GitSource { url, reference })
}
fn expand_shorthand(repo: &str) -> Result<String, StarterError> {
let parts: Vec<&str> = repo.split('/').collect();
let valid = parts.len() == 2
&& parts.iter().all(|p| {
!p.is_empty()
&& p.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
});
if !valid {
return Err(StarterError::InvalidSource(format!(
"'{repo}' is not a valid starter source — expected a built-in name, \
a local path, a full git URL, or an 'owner/repo' GitHub shorthand"
)));
}
Ok(format!("https://github.com/{repo}.git"))
}
fn is_full_sha(r: &str) -> bool {
(r.len() == 40 || r.len() == 64) && r.chars().all(|c| c.is_ascii_hexdigit())
}
pub fn clone_into(source: &GitSource, dest: &Path) -> Result<(), StarterError> {
let map_io = |e: std::io::Error| {
if e.kind() == std::io::ErrorKind::NotFound {
StarterError::GitNotInstalled
} else {
StarterError::CloneFailed(e.to_string())
}
};
match &source.reference {
Some(r) if is_full_sha(r) => {
let output = Command::new("git")
.args(["clone", &source.url])
.arg(dest)
.output()
.map_err(map_io)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(StarterError::CloneFailed(format!(
"git clone of {} failed: {}",
source.url,
stderr.trim()
)));
}
let output = Command::new("git")
.args(["-C"])
.arg(dest)
.args(["checkout", r])
.output()
.map_err(map_io)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(StarterError::CloneFailed(format!(
"git checkout of {} in {} failed: {}",
r,
dest.display(),
stderr.trim()
)));
}
}
ref_ => {
let mut cmd = Command::new("git");
cmd.arg("clone").arg("--depth").arg("1");
if let Some(reference) = ref_ {
cmd.arg("--branch").arg(reference);
}
cmd.arg(&source.url).arg(dest);
let output = cmd.output().map_err(map_io)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(StarterError::CloneFailed(format!(
"git clone of {} failed: {}",
source.url,
stderr.trim()
)));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shorthand_expands_to_github_https() {
let s = resolve("madmax983/autumn-starter-blog", None).unwrap();
assert_eq!(
s.url,
"https://github.com/madmax983/autumn-starter-blog.git"
);
assert_eq!(s.reference, None);
}
#[test]
fn shorthand_with_at_ref_pins_ref() {
let s = resolve("owner/repo@v1.2.0", None).unwrap();
assert_eq!(s.url, "https://github.com/owner/repo.git");
assert_eq!(s.reference.as_deref(), Some("v1.2.0"));
}
#[test]
fn starter_ref_flag_pins_ref() {
let s = resolve("owner/repo", Some("main")).unwrap();
assert_eq!(s.reference.as_deref(), Some("main"));
}
#[test]
fn at_suffix_and_ref_flag_conflict() {
let err = resolve("owner/repo@v1", Some("main")).unwrap_err();
assert!(matches!(err, StarterError::AmbiguousRef));
}
#[test]
fn full_https_url_passes_through() {
let s = resolve("https://gitlab.com/group/proj.git", None).unwrap();
assert_eq!(s.url, "https://gitlab.com/group/proj.git");
}
#[test]
fn full_https_url_ref_only_from_flag() {
let s = resolve("https://gitlab.com/group/proj.git", Some("dev")).unwrap();
assert_eq!(s.url, "https://gitlab.com/group/proj.git");
assert_eq!(s.reference.as_deref(), Some("dev"));
}
#[test]
fn scp_style_url_not_split_on_at() {
let s = resolve("git@github.com:owner/repo.git", None).unwrap();
assert_eq!(s.url, "git@github.com:owner/repo.git");
assert_eq!(s.reference, None);
}
#[test]
fn invalid_shorthand_rejected() {
assert!(matches!(
resolve("not-a-repo", None).unwrap_err(),
StarterError::InvalidSource(_)
));
assert!(matches!(
resolve("too/many/parts", None).unwrap_err(),
StarterError::InvalidSource(_)
));
}
fn git_available() -> bool {
Command::new("git")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
}
#[test]
fn clone_into_fetches_a_file_url_repo() {
if !git_available() {
eprintln!("skipping: git not installed");
return;
}
let origin = tempfile::TempDir::new().unwrap();
let run = |args: &[&str]| {
let ok = Command::new("git")
.args(args)
.current_dir(origin.path())
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
};
run(&["init", "-q"]);
run(&["config", "user.email", "t@t.test"]);
run(&["config", "user.name", "Test"]);
std::fs::write(origin.path().join("hello.txt"), "hi").unwrap();
run(&["add", "."]);
run(&["commit", "-q", "-m", "init"]);
let dest = tempfile::TempDir::new().unwrap();
let clone_dir = dest.path().join("clone");
let source = GitSource {
url: format!("file://{}", origin.path().display()),
reference: None,
};
clone_into(&source, &clone_dir).unwrap();
assert_eq!(
std::fs::read_to_string(clone_dir.join("hello.txt")).unwrap(),
"hi"
);
}
}