1use crate::conf::{expand_path, CloneConf};
20use crate::git::Git;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24#[derive(Debug, PartialEq, Eq)]
25pub enum Outcome {
26 Cloned,
27 Skipped,
28 Failed(String),
29}
30
31#[derive(Debug)]
32pub struct CloneReport {
33 pub name: String,
34 pub dir: PathBuf,
35 pub outcome: Outcome,
36 pub command: String,
37}
38
39pub struct Opts {
40 pub submodule_branch: bool,
41 pub direnv: bool,
42 pub user_name: Option<String>,
45 pub user_email: Option<String>,
47 pub conf_path: Option<String>,
51}
52
53impl Default for Opts {
54 fn default() -> Self {
55 Self {
56 submodule_branch: true,
57 direnv: true,
58 user_name: None,
59 user_email: None,
60 conf_path: None,
61 }
62 }
63}
64
65pub(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";
67
68pub(crate) fn sh_squote(s: &str) -> String {
71 format!("'{}'", s.replace('\'', "'\\''"))
72}
73
74fn submodule_identity_cmd(user_name: Option<&str>, user_email: Option<&str>) -> Option<String> {
78 let parts: Vec<String> = [("user.name", user_name), ("user.email", user_email)]
79 .into_iter()
80 .filter_map(|(k, v)| v.map(|v| format!("git config {k} {}", sh_squote(v))))
81 .collect();
82 (!parts.is_empty()).then(|| parts.join("; "))
83}
84
85pub fn insteadof_pair(alias: &str, hostname: &str, ns: &str) -> (String, String) {
92 (
93 format!("url.{alias}:{ns}/.insteadOf"),
94 format!("git@{hostname}:{ns}/"),
95 )
96}
97
98pub fn distinct_namespaces(conf: &CloneConf) -> Vec<String> {
101 let mut out: Vec<String> = Vec::new();
102 for r in &conf.repo {
103 if let Some(ns) = conf.namespace_for(r) {
104 if !out.iter().any(|n| n == ns) {
105 out.push(ns.to_string());
106 }
107 }
108 }
109 out
110}
111
112pub(crate) fn run_hooks(cmds: &[String], cwd: &Path, env: &[(&str, &str)]) -> Result<(), String> {
116 for cmd in cmds {
117 println!("+ {cmd}");
118 let mut c = Command::new("sh");
119 c.arg("-c").arg(cmd).current_dir(cwd);
120 for (k, v) in env {
121 c.env(k, v);
122 }
123 match c.status() {
124 Ok(s) if s.success() => {}
125 Ok(s) => return Err(format!("hook `{cmd}` exited {}", s.code().unwrap_or(-1))),
126 Err(e) => return Err(format!("hook `{cmd}` failed to start: {e}")),
127 }
128 }
129 Ok(())
130}
131
132pub fn clone_all<G: Git>(git: &G, conf: &CloneConf, opts: &Opts) -> Vec<CloneReport> {
135 conf.repo
136 .iter()
137 .map(|r| {
138 let name = r.name();
139 let dir_s = expand_path(&r.dir, |k| std::env::var(k).ok());
140 let dir = PathBuf::from(&dir_s);
141 let ns = match conf.namespace_for(r) {
144 Some(n) => n.to_string(),
145 None => {
146 let e = format!("no namespace for {}", r.dir);
147 println!("FAILED {name:<28} {e}");
148 return CloneReport {
149 name,
150 dir,
151 outcome: Outcome::Failed(e),
152 command: String::new(),
153 };
154 }
155 };
156 let url = format!("{}:{}/{}.git", conf.host, ns, name);
157
158 let mut args: Vec<String> = Vec::new();
160 args.extend(conf.git_flags.iter().cloned());
161 args.push("clone".into());
162 if let Some(d) = r.depth {
163 args.push("--depth".into());
164 args.push(d.to_string());
165 }
166 if let Some(b) = &r.branch {
167 args.push("--branch".into());
168 args.push(b.clone());
169 args.push("--single-branch".into());
170 }
171 args.push("--recurse-submodules".into());
172 args.extend(conf.clone_flags.iter().cloned());
173 args.extend(r.clone_flags.iter().cloned());
174 args.push(url.clone());
175 args.push(dir_s.clone());
176 let command = format!("git {}", args.join(" "));
177
178 let mk = |outcome| CloneReport {
179 name: name.clone(),
180 dir: dir.clone(),
181 outcome,
182 command: command.clone(),
183 };
184
185 if dir.join(".git").exists() {
186 println!("+ {command}");
187 println!("skipped {name:<28} {dir_s} (exists)");
188 return mk(Outcome::Skipped);
189 }
190
191 let env = [
192 ("GKIT_REPO", name.as_str()),
193 ("GKIT_DIR", dir_s.as_str()),
194 ("GKIT_URL", url.as_str()),
195 ("GKIT_HOST", conf.host.as_str()),
196 ("GKIT_NAMESPACE", ns.as_str()),
197 ("GKIT_USER_NAME", opts.user_name.as_deref().unwrap_or("")),
198 ("GKIT_USER_EMAIL", opts.user_email.as_deref().unwrap_or("")),
199 ];
200
201 let parent = dir.parent().unwrap_or(Path::new("."));
203 let _ = std::fs::create_dir_all(parent);
204 let pre: Vec<String> = conf
205 .pre_clone
206 .0
207 .iter()
208 .chain(r.pre_clone.0.iter())
209 .cloned()
210 .collect();
211 if let Err(e) = run_hooks(&pre, parent, &env) {
212 println!("FAILED {name:<28} {e}");
213 return mk(Outcome::Failed(e));
214 }
215
216 println!("+ {command}");
218 let refs: Vec<&str> = args.iter().map(String::as_str).collect();
219 let out = git.run(Path::new("."), &refs);
220 if !out.success {
221 let e = out.stderr.trim().to_string();
222 println!("FAILED {name:<28} {}", e.lines().next().unwrap_or(""));
223 return mk(Outcome::Failed(e));
224 }
225
226 let identity: Vec<(&str, &str)> = [
229 ("user.name", opts.user_name.as_deref()),
230 ("user.email", opts.user_email.as_deref()),
231 ]
232 .into_iter()
233 .filter_map(|(k, v)| Some((k, v?)))
234 .collect();
235 for (key, val) in &identity {
237 println!("+ git config {key} {val}");
238 let out = git.run(&dir, &["config", key, val]);
239 if !out.success {
240 let e = format!("git config {key} failed: {}", out.stderr.trim());
241 println!("FAILED {name:<28} {e}");
242 return mk(Outcome::Failed(e));
243 }
244 }
245 if let Some(cp) = opts.conf_path.as_deref() {
248 println!("+ git config gkit.conf {cp}");
249 let out = git.run(&dir, &["config", "gkit.conf", cp]);
250 if !out.success {
251 let e = format!("git config gkit.conf failed: {}", out.stderr.trim());
252 println!("FAILED {name:<28} {e}");
253 return mk(Outcome::Failed(e));
254 }
255 }
256 if let Some(body) =
260 submodule_identity_cmd(opts.user_name.as_deref(), opts.user_email.as_deref())
261 {
262 println!("+ git submodule foreach --recursive {body}");
263 let out = git.run(
264 &dir,
265 &["submodule", "foreach", "--recursive", body.as_str()],
266 );
267 if !out.success {
268 let e = format!("submodule identity failed: {}", out.stderr.trim());
269 println!("FAILED {name:<28} {e}");
270 return mk(Outcome::Failed(e));
271 }
272 }
273 if opts.submodule_branch {
275 let _ = git.run(
276 &dir,
277 &["submodule", "foreach", "--recursive", SUBMODULE_SWITCH],
278 );
279 }
280 if opts.direnv && dir.join(".envrc").exists() {
281 let _ = Command::new("direnv").arg("allow").arg(&dir).output(); }
283
284 let post: Vec<String> = conf
286 .post_clone
287 .0
288 .iter()
289 .chain(r.post_clone.0.iter())
290 .cloned()
291 .collect();
292 if let Err(e) = run_hooks(&post, &dir, &env) {
293 println!("FAILED {name:<28} {e}");
294 return mk(Outcome::Failed(e));
295 }
296
297 println!("cloned {name:<28} {dir_s}");
298 mk(Outcome::Cloned)
299 })
300 .collect()
301}
302
303#[cfg(test)]
304mod tests {
305 use super::{sh_squote, submodule_identity_cmd};
306 use crate::conf;
307
308 #[test]
309 fn submodule_identity_cmd_quotes_and_skips() {
310 assert_eq!(
312 submodule_identity_cmd(Some("Jane Dev"), Some("jane@acme.com")).as_deref(),
313 Some("git config user.name 'Jane Dev'; git config user.email 'jane@acme.com'")
314 );
315 assert_eq!(
317 submodule_identity_cmd(Some("Jane"), None).as_deref(),
318 Some("git config user.name 'Jane'")
319 );
320 assert_eq!(submodule_identity_cmd(None, None), None);
322 assert_eq!(
324 submodule_identity_cmd(Some("O'Brien"), None).as_deref(),
325 Some(r"git config user.name 'O'\''Brien'")
326 );
327 assert_eq!(sh_squote("a b"), "'a b'");
328 }
329
330 #[test]
331 fn insteadof_pair_is_namespace_scoped() {
332 assert_eq!(
334 super::insteadof_pair("tlbb", "bitbucket.org", "codogenics"),
335 (
336 "url.tlbb:codogenics/.insteadOf".to_string(),
337 "git@bitbucket.org:codogenics/".to_string()
338 )
339 );
340 assert_eq!(
342 super::insteadof_pair("ctl", "gitlab.com", "grp/sub").1,
343 "git@gitlab.com:grp/sub/"
344 );
345 }
346
347 #[test]
348 fn distinct_namespaces_dedups_in_order() {
349 let c = conf::parse(
350 "host=\"h\"\nnamespace=\"glob\"\n\
351 [[repo]]\ndir=\"$H/a\"\n\
352 [[repo]]\ndir=\"$H/b\"\nnamespace=\"bob\"\n\
353 [[repo]]\ndir=\"$H/c\"\n",
354 )
355 .unwrap();
356 assert_eq!(super::distinct_namespaces(&c), vec!["glob", "bob"]);
358 }
359
360 #[test]
361 fn opts_default_has_no_conf_path() {
362 assert_eq!(super::Opts::default().conf_path, None);
365 }
366
367 #[test]
368 fn builds_expected_url_shape() {
369 let c = conf::parse("host = \"tlbb\"\nnamespace = \"example-org\"\n[[repo]]\ndir = \"$HOME/x/cosp\"\ndepth = 1\n").unwrap();
370 assert_eq!(c.repo[0].name(), "cosp");
371 assert_eq!(c.repo[0].depth, Some(1));
372 let ns = c.namespace_for(&c.repo[0]).unwrap();
373 let url = format!("{}:{}/{}.git", c.host, ns, c.repo[0].name());
374 assert_eq!(url, "tlbb:example-org/cosp.git");
375 }
376
377 #[test]
378 fn per_repo_namespace_drives_url() {
379 let c = conf::parse("host=\"gh\"\n[[repo]]\ndir=\"$HOME/x/foo\"\nnamespace=\"alice\"\n")
380 .unwrap();
381 let ns = c.namespace_for(&c.repo[0]).unwrap();
382 let url = format!("{}:{}/{}.git", c.host, ns, c.repo[0].name());
383 assert_eq!(url, "gh:alice/foo.git");
384 }
385}