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