use crate::conf::{expand_path, CloneConf, Repo};
use crate::git::Git;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
Cloned,
Skipped,
Failed(String),
}
#[derive(Debug)]
pub struct CloneReport {
pub name: String,
pub dir: PathBuf,
pub outcome: Outcome,
pub command: String,
}
pub struct Opts {
pub submodule_branch: bool,
pub direnv: bool,
pub user_name: Option<String>,
pub user_email: Option<String>,
pub conf_path: Option<String>,
}
impl Default for Opts {
fn default() -> Self {
Self {
submodule_branch: true,
direnv: true,
user_name: None,
user_email: None,
conf_path: None,
}
}
}
pub(crate) const SUBMODULE_SWITCH: &str = "b=$(git config -f \"$toplevel/.gitmodules\" \"submodule.$name.branch\" 2>/dev/null || echo main); git switch \"$b\" 2>/dev/null || true";
pub(crate) fn sh_squote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn submodule_identity_cmd(user_name: Option<&str>, user_email: Option<&str>) -> Option<String> {
let parts: Vec<String> = [("user.name", user_name), ("user.email", user_email)]
.into_iter()
.filter_map(|(k, v)| v.map(|v| format!("git config {k} {}", sh_squote(v))))
.collect();
(!parts.is_empty()).then(|| parts.join("; "))
}
pub fn insteadof_pair(alias: &str, hostname: &str, ns: &str) -> (String, String) {
(
format!("url.{alias}:{ns}/.insteadOf"),
format!("git@{hostname}:{ns}/"),
)
}
pub fn distinct_namespaces(conf: &CloneConf) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for r in &conf.repo {
if let Some(ns) = conf.namespace_for(r) {
if !out.iter().any(|n| n == ns) {
out.push(ns.to_string());
}
}
}
out
}
pub(crate) fn run_hooks(cmds: &[String], cwd: &Path, env: &[(&str, &str)]) -> Result<(), String> {
for cmd in cmds {
println!("+ {cmd}");
let mut c = Command::new("sh");
c.arg("-e").arg("-c").arg(cmd).current_dir(cwd);
for (k, v) in env {
c.env(k, v);
}
match c.status() {
Ok(s) if s.success() => {}
Ok(s) => return Err(format!("hook `{cmd}` exited {}", s.code().unwrap_or(-1))),
Err(e) => return Err(format!("hook `{cmd}` failed to start: {e}")),
}
}
Ok(())
}
fn clone_args(conf: &CloneConf, r: &Repo, url: &str, dir_s: &str) -> Vec<String> {
let mut args: Vec<String> = Vec::new();
args.extend(conf.git_flags.iter().cloned());
args.push("clone".into());
if let Some(d) = r.depth {
args.push("--depth".into());
args.push(d.to_string());
}
if let Some(b) = &r.branch {
args.push("--branch".into());
args.push(b.clone());
}
if r.single_branch {
args.push("--single-branch".into());
}
args.push("--recurse-submodules".into());
args.extend(conf.clone_flags.iter().cloned());
args.extend(r.clone_flags.iter().cloned());
args.push(url.to_string());
args.push(dir_s.to_string());
args
}
pub fn clone_all<G: Git>(git: &G, conf: &CloneConf, opts: &Opts) -> Vec<CloneReport> {
conf.repo
.iter()
.map(|r| {
let name = r.name();
let dir_s = expand_path(&r.dir, |k| std::env::var(k).ok());
let dir = PathBuf::from(&dir_s);
let ns = match conf.namespace_for(r) {
Some(n) => n.to_string(),
None => {
let e = format!("no namespace for {}", r.dir);
println!("FAILED {name:<28} {e}");
return CloneReport {
name,
dir,
outcome: Outcome::Failed(e),
command: String::new(),
};
}
};
let url = format!("{}:{}/{}.git", conf.host, ns, name);
let args = clone_args(conf, r, &url, &dir_s);
let command = format!("git {}", args.join(" "));
let mk = |outcome| CloneReport {
name: name.clone(),
dir: dir.clone(),
outcome,
command: command.clone(),
};
if dir.join(".git").exists() {
println!("+ {command}");
println!("skipped {name:<28} {dir_s} (exists)");
return mk(Outcome::Skipped);
}
let env = [
("GKIT_REPO", name.as_str()),
("GKIT_DIR", dir_s.as_str()),
("GKIT_URL", url.as_str()),
("GKIT_HOST", conf.host.as_str()),
("GKIT_NAMESPACE", ns.as_str()),
("GKIT_USER_NAME", opts.user_name.as_deref().unwrap_or("")),
("GKIT_USER_EMAIL", opts.user_email.as_deref().unwrap_or("")),
];
let parent = dir.parent().unwrap_or(Path::new("."));
let _ = std::fs::create_dir_all(parent);
let pre: Vec<String> = conf
.pre_clone
.0
.iter()
.chain(r.pre_clone.0.iter())
.cloned()
.collect();
if let Err(e) = run_hooks(&pre, parent, &env) {
println!("FAILED {name:<28} {e}");
return mk(Outcome::Failed(e));
}
println!("+ {command}");
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let out = git.run(Path::new("."), &refs);
if !out.success {
let e = out.stderr.trim().to_string();
println!("FAILED {name:<28} {}", e.lines().next().unwrap_or(""));
return mk(Outcome::Failed(e));
}
let identity: Vec<(&str, &str)> = [
("user.name", opts.user_name.as_deref()),
("user.email", opts.user_email.as_deref()),
]
.into_iter()
.filter_map(|(k, v)| Some((k, v?)))
.collect();
for (key, val) in &identity {
println!("+ git config {key} {val}");
let out = git.run(&dir, &["config", key, val]);
if !out.success {
let e = format!("git config {key} failed: {}", out.stderr.trim());
println!("FAILED {name:<28} {e}");
return mk(Outcome::Failed(e));
}
}
if let Some(cp) = opts.conf_path.as_deref() {
println!("+ git config gkit.conf {cp}");
let out = git.run(&dir, &["config", "gkit.conf", cp]);
if !out.success {
let e = format!("git config gkit.conf failed: {}", out.stderr.trim());
println!("FAILED {name:<28} {e}");
return mk(Outcome::Failed(e));
}
}
if let Some(body) =
submodule_identity_cmd(opts.user_name.as_deref(), opts.user_email.as_deref())
{
println!("+ git submodule foreach --recursive {body}");
let out = git.run(
&dir,
&["submodule", "foreach", "--recursive", body.as_str()],
);
if !out.success {
let e = format!("submodule identity failed: {}", out.stderr.trim());
println!("FAILED {name:<28} {e}");
return mk(Outcome::Failed(e));
}
}
if opts.submodule_branch {
let _ = git.run(
&dir,
&["submodule", "foreach", "--recursive", SUBMODULE_SWITCH],
);
}
if opts.direnv && dir.join(".envrc").exists() {
let _ = Command::new("direnv").arg("allow").arg(&dir).output(); }
let post: Vec<String> = conf
.post_clone
.0
.iter()
.chain(r.post_clone.0.iter())
.cloned()
.collect();
if let Err(e) = run_hooks(&post, &dir, &env) {
println!("FAILED {name:<28} {e}");
return mk(Outcome::Failed(e));
}
println!("cloned {name:<28} {dir_s}");
mk(Outcome::Cloned)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{sh_squote, submodule_identity_cmd};
use crate::conf;
#[test]
fn submodule_identity_cmd_quotes_and_skips() {
assert_eq!(
submodule_identity_cmd(Some("Jane Dev"), Some("jane@acme.com")).as_deref(),
Some("git config user.name 'Jane Dev'; git config user.email 'jane@acme.com'")
);
assert_eq!(
submodule_identity_cmd(Some("Jane"), None).as_deref(),
Some("git config user.name 'Jane'")
);
assert_eq!(submodule_identity_cmd(None, None), None);
assert_eq!(
submodule_identity_cmd(Some("O'Brien"), None).as_deref(),
Some(r"git config user.name 'O'\''Brien'")
);
assert_eq!(sh_squote("a b"), "'a b'");
}
#[test]
fn insteadof_pair_is_namespace_scoped() {
assert_eq!(
super::insteadof_pair("tlbb", "bitbucket.org", "codogenics"),
(
"url.tlbb:codogenics/.insteadOf".to_string(),
"git@bitbucket.org:codogenics/".to_string()
)
);
assert_eq!(
super::insteadof_pair("ctl", "gitlab.com", "grp/sub").1,
"git@gitlab.com:grp/sub/"
);
}
#[test]
fn distinct_namespaces_dedups_in_order() {
let c = conf::parse(
"host=\"h\"\nnamespace=\"glob\"\n\
[[repo]]\ndir=\"$H/a\"\n\
[[repo]]\ndir=\"$H/b\"\nnamespace=\"bob\"\n\
[[repo]]\ndir=\"$H/c\"\n",
)
.unwrap();
assert_eq!(super::distinct_namespaces(&c), vec!["glob", "bob"]);
}
#[test]
fn opts_default_has_no_conf_path() {
assert_eq!(super::Opts::default().conf_path, None);
}
#[test]
fn builds_expected_url_shape() {
let c = conf::parse("host = \"tlbb\"\nnamespace = \"example-org\"\n[[repo]]\ndir = \"$HOME/x/cosp\"\ndepth = 1\n").unwrap();
assert_eq!(c.repo[0].name(), "cosp");
assert_eq!(c.repo[0].depth, Some(1));
let ns = c.namespace_for(&c.repo[0]).unwrap();
let url = format!("{}:{}/{}.git", c.host, ns, c.repo[0].name());
assert_eq!(url, "tlbb:example-org/cosp.git");
}
#[test]
fn branch_is_full_clone_by_default() {
let c = conf::parse(
"host=\"tlbb\"\nnamespace=\"codogenics\"\n\
[[repo]]\ndir=\"$HOME/scratch-spark\"\nname=\"spark4beginners\"\n\
branch=\"SCB-543-spark-scala-chapter2\"\n",
)
.unwrap();
let args = super::clone_args(
&c,
&c.repo[0],
"tlbb:codogenics/spark4beginners.git",
"/h/s",
);
assert_eq!(
args,
[
"clone",
"--branch",
"SCB-543-spark-scala-chapter2",
"--recurse-submodules",
"tlbb:codogenics/spark4beginners.git",
"/h/s",
]
);
assert!(!args.iter().any(|a| a == "--single-branch"));
}
#[test]
fn single_branch_true_adds_flag() {
let c = conf::parse(
"host=\"h\"\nnamespace=\"o\"\n\
[[repo]]\ndir=\"$H/r\"\nbranch=\"dev\"\nsingle-branch=true\n",
)
.unwrap();
let args = super::clone_args(&c, &c.repo[0], "h:o/r.git", "/h/r");
assert_eq!(
args,
[
"clone",
"--branch",
"dev",
"--single-branch",
"--recurse-submodules",
"h:o/r.git",
"/h/r"
]
);
}
#[test]
fn single_branch_without_branch_clones_default_only() {
let c = conf::parse(
"host=\"h\"\nnamespace=\"o\"\n[[repo]]\ndir=\"$H/r\"\nsingle-branch=true\n",
)
.unwrap();
let args = super::clone_args(&c, &c.repo[0], "h:o/r.git", "/h/r");
assert_eq!(
args,
[
"clone",
"--single-branch",
"--recurse-submodules",
"h:o/r.git",
"/h/r"
]
);
assert!(!args.iter().any(|a| a == "--branch"));
}
#[test]
fn per_repo_namespace_drives_url() {
let c = conf::parse("host=\"gh\"\n[[repo]]\ndir=\"$HOME/x/foo\"\nnamespace=\"alice\"\n")
.unwrap();
let ns = c.namespace_for(&c.repo[0]).unwrap();
let url = format!("{}:{}/{}.git", c.host, ns, c.repo[0].name());
assert_eq!(url, "gh:alice/foo.git");
}
}