use crate::conf::{expand_path, CloneConf};
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>,
}
impl Default for Opts {
fn default() -> Self {
Self {
submodule_branch: true,
direnv: true,
user_name: None,
user_email: None,
}
}
}
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";
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("; "))
}
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("-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(())
}
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 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());
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.clone());
args.push(dir_s.clone());
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(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 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 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");
}
}