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}
48
49impl Default for Opts {
50 fn default() -> Self {
51 Self {
52 submodule_branch: true,
53 direnv: true,
54 user_name: None,
55 user_email: None,
56 }
57 }
58}
59
60const SUBMODULE_SWITCH: &str = "b=$(git config -f \"$toplevel/.gitmodules\" \"submodule.$name.branch\" 2>/dev/null || echo main); git switch \"$b\" 2>/dev/null || true";
61
62fn sh_squote(s: &str) -> String {
65 format!("'{}'", s.replace('\'', "'\\''"))
66}
67
68fn submodule_identity_cmd(user_name: Option<&str>, user_email: Option<&str>) -> Option<String> {
72 let parts: Vec<String> = [("user.name", user_name), ("user.email", user_email)]
73 .into_iter()
74 .filter_map(|(k, v)| v.map(|v| format!("git config {k} {}", sh_squote(v))))
75 .collect();
76 (!parts.is_empty()).then(|| parts.join("; "))
77}
78
79pub(crate) fn run_hooks(cmds: &[String], cwd: &Path, env: &[(&str, &str)]) -> Result<(), String> {
83 for cmd in cmds {
84 println!("+ {cmd}");
85 let mut c = Command::new("sh");
86 c.arg("-c").arg(cmd).current_dir(cwd);
87 for (k, v) in env {
88 c.env(k, v);
89 }
90 match c.status() {
91 Ok(s) if s.success() => {}
92 Ok(s) => return Err(format!("hook `{cmd}` exited {}", s.code().unwrap_or(-1))),
93 Err(e) => return Err(format!("hook `{cmd}` failed to start: {e}")),
94 }
95 }
96 Ok(())
97}
98
99pub fn clone_all<G: Git>(git: &G, conf: &CloneConf, opts: &Opts) -> Vec<CloneReport> {
102 conf.repo
103 .iter()
104 .map(|r| {
105 let name = r.name();
106 let dir_s = expand_path(&r.dir, |k| std::env::var(k).ok());
107 let dir = PathBuf::from(&dir_s);
108 let ns = match conf.namespace_for(r) {
111 Some(n) => n.to_string(),
112 None => {
113 let e = format!("no namespace for {}", r.dir);
114 println!("FAILED {name:<28} {e}");
115 return CloneReport {
116 name,
117 dir,
118 outcome: Outcome::Failed(e),
119 command: String::new(),
120 };
121 }
122 };
123 let url = format!("{}:{}/{}.git", conf.host, ns, name);
124
125 let mut args: Vec<String> = Vec::new();
127 args.extend(conf.git_flags.iter().cloned());
128 args.push("clone".into());
129 if let Some(d) = r.depth {
130 args.push("--depth".into());
131 args.push(d.to_string());
132 }
133 if let Some(b) = &r.branch {
134 args.push("--branch".into());
135 args.push(b.clone());
136 args.push("--single-branch".into());
137 }
138 args.push("--recurse-submodules".into());
139 args.extend(conf.clone_flags.iter().cloned());
140 args.extend(r.clone_flags.iter().cloned());
141 args.push(url.clone());
142 args.push(dir_s.clone());
143 let command = format!("git {}", args.join(" "));
144
145 let mk = |outcome| CloneReport {
146 name: name.clone(),
147 dir: dir.clone(),
148 outcome,
149 command: command.clone(),
150 };
151
152 if dir.join(".git").exists() {
153 println!("+ {command}");
154 println!("skipped {name:<28} {dir_s} (exists)");
155 return mk(Outcome::Skipped);
156 }
157
158 let env = [
159 ("GKIT_REPO", name.as_str()),
160 ("GKIT_DIR", dir_s.as_str()),
161 ("GKIT_URL", url.as_str()),
162 ("GKIT_HOST", conf.host.as_str()),
163 ("GKIT_NAMESPACE", ns.as_str()),
164 ("GKIT_USER_NAME", opts.user_name.as_deref().unwrap_or("")),
165 ("GKIT_USER_EMAIL", opts.user_email.as_deref().unwrap_or("")),
166 ];
167
168 let parent = dir.parent().unwrap_or(Path::new("."));
170 let _ = std::fs::create_dir_all(parent);
171 let pre: Vec<String> = conf
172 .pre_clone
173 .0
174 .iter()
175 .chain(r.pre_clone.0.iter())
176 .cloned()
177 .collect();
178 if let Err(e) = run_hooks(&pre, parent, &env) {
179 println!("FAILED {name:<28} {e}");
180 return mk(Outcome::Failed(e));
181 }
182
183 println!("+ {command}");
185 let refs: Vec<&str> = args.iter().map(String::as_str).collect();
186 let out = git.run(Path::new("."), &refs);
187 if !out.success {
188 let e = out.stderr.trim().to_string();
189 println!("FAILED {name:<28} {}", e.lines().next().unwrap_or(""));
190 return mk(Outcome::Failed(e));
191 }
192
193 let identity: Vec<(&str, &str)> = [
196 ("user.name", opts.user_name.as_deref()),
197 ("user.email", opts.user_email.as_deref()),
198 ]
199 .into_iter()
200 .filter_map(|(k, v)| Some((k, v?)))
201 .collect();
202 for (key, val) in &identity {
204 println!("+ git config {key} {val}");
205 let out = git.run(&dir, &["config", key, val]);
206 if !out.success {
207 let e = format!("git config {key} failed: {}", out.stderr.trim());
208 println!("FAILED {name:<28} {e}");
209 return mk(Outcome::Failed(e));
210 }
211 }
212 if let Some(body) =
216 submodule_identity_cmd(opts.user_name.as_deref(), opts.user_email.as_deref())
217 {
218 println!("+ git submodule foreach --recursive {body}");
219 let out = git.run(
220 &dir,
221 &["submodule", "foreach", "--recursive", body.as_str()],
222 );
223 if !out.success {
224 let e = format!("submodule identity failed: {}", out.stderr.trim());
225 println!("FAILED {name:<28} {e}");
226 return mk(Outcome::Failed(e));
227 }
228 }
229 if opts.submodule_branch {
231 let _ = git.run(
232 &dir,
233 &["submodule", "foreach", "--recursive", SUBMODULE_SWITCH],
234 );
235 }
236 if opts.direnv && dir.join(".envrc").exists() {
237 let _ = Command::new("direnv").arg("allow").arg(&dir).output(); }
239
240 let post: Vec<String> = conf
242 .post_clone
243 .0
244 .iter()
245 .chain(r.post_clone.0.iter())
246 .cloned()
247 .collect();
248 if let Err(e) = run_hooks(&post, &dir, &env) {
249 println!("FAILED {name:<28} {e}");
250 return mk(Outcome::Failed(e));
251 }
252
253 println!("cloned {name:<28} {dir_s}");
254 mk(Outcome::Cloned)
255 })
256 .collect()
257}
258
259#[cfg(test)]
260mod tests {
261 use super::{sh_squote, submodule_identity_cmd};
262 use crate::conf;
263
264 #[test]
265 fn submodule_identity_cmd_quotes_and_skips() {
266 assert_eq!(
268 submodule_identity_cmd(Some("Jane Dev"), Some("jane@acme.com")).as_deref(),
269 Some("git config user.name 'Jane Dev'; git config user.email 'jane@acme.com'")
270 );
271 assert_eq!(
273 submodule_identity_cmd(Some("Jane"), None).as_deref(),
274 Some("git config user.name 'Jane'")
275 );
276 assert_eq!(submodule_identity_cmd(None, None), None);
278 assert_eq!(
280 submodule_identity_cmd(Some("O'Brien"), None).as_deref(),
281 Some(r"git config user.name 'O'\''Brien'")
282 );
283 assert_eq!(sh_squote("a b"), "'a b'");
284 }
285
286 #[test]
287 fn builds_expected_url_shape() {
288 let c = conf::parse("host = \"tlbb\"\nnamespace = \"example-org\"\n[[repo]]\ndir = \"$HOME/x/cosp\"\ndepth = 1\n").unwrap();
289 assert_eq!(c.repo[0].name(), "cosp");
290 assert_eq!(c.repo[0].depth, Some(1));
291 let ns = c.namespace_for(&c.repo[0]).unwrap();
292 let url = format!("{}:{}/{}.git", c.host, ns, c.repo[0].name());
293 assert_eq!(url, "tlbb:example-org/cosp.git");
294 }
295
296 #[test]
297 fn per_repo_namespace_drives_url() {
298 let c = conf::parse("host=\"gh\"\n[[repo]]\ndir=\"$HOME/x/foo\"\nnamespace=\"alice\"\n")
299 .unwrap();
300 let ns = c.namespace_for(&c.repo[0]).unwrap();
301 let url = format!("{}:{}/{}.git", c.host, ns, c.repo[0].name());
302 assert_eq!(url, "gh:alice/foo.git");
303 }
304}