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 run_hooks(cmds: &[String], cwd: &Path, env: &[(&str, &str)]) -> Result<(), String> {
65 for cmd in cmds {
66 println!("+ {cmd}");
67 let mut c = Command::new("sh");
68 c.arg("-c").arg(cmd).current_dir(cwd);
69 for (k, v) in env {
70 c.env(k, v);
71 }
72 match c.status() {
73 Ok(s) if s.success() => {}
74 Ok(s) => return Err(format!("hook `{cmd}` exited {}", s.code().unwrap_or(-1))),
75 Err(e) => return Err(format!("hook `{cmd}` failed to start: {e}")),
76 }
77 }
78 Ok(())
79}
80
81pub fn clone_all<G: Git>(git: &G, conf: &CloneConf, opts: &Opts) -> Vec<CloneReport> {
84 conf.repo
85 .iter()
86 .map(|r| {
87 let name = r.name();
88 let dir_s = expand_path(&r.dir, |k| std::env::var(k).ok());
89 let dir = PathBuf::from(&dir_s);
90 let ns = match conf.namespace_for(r) {
93 Some(n) => n.to_string(),
94 None => {
95 let e = format!("no namespace for {}", r.dir);
96 println!("FAILED {name:<28} {e}");
97 return CloneReport {
98 name,
99 dir,
100 outcome: Outcome::Failed(e),
101 command: String::new(),
102 };
103 }
104 };
105 let url = format!("{}:{}/{}.git", conf.host, ns, name);
106
107 let mut args: Vec<String> = Vec::new();
109 args.extend(conf.git_flags.iter().cloned());
110 args.push("clone".into());
111 if let Some(d) = r.depth {
112 args.push("--depth".into());
113 args.push(d.to_string());
114 }
115 if let Some(b) = &r.branch {
116 args.push("--branch".into());
117 args.push(b.clone());
118 args.push("--single-branch".into());
119 }
120 args.push("--recurse-submodules".into());
121 args.extend(conf.clone_flags.iter().cloned());
122 args.extend(r.clone_flags.iter().cloned());
123 args.push(url.clone());
124 args.push(dir_s.clone());
125 let command = format!("git {}", args.join(" "));
126
127 let mk = |outcome| CloneReport {
128 name: name.clone(),
129 dir: dir.clone(),
130 outcome,
131 command: command.clone(),
132 };
133
134 if dir.join(".git").exists() {
135 println!("+ {command}");
136 println!("skipped {name:<28} {dir_s} (exists)");
137 return mk(Outcome::Skipped);
138 }
139
140 let env = [
141 ("GKIT_REPO", name.as_str()),
142 ("GKIT_DIR", dir_s.as_str()),
143 ("GKIT_URL", url.as_str()),
144 ("GKIT_HOST", conf.host.as_str()),
145 ("GKIT_NAMESPACE", ns.as_str()),
146 ("GKIT_USER_NAME", opts.user_name.as_deref().unwrap_or("")),
147 ("GKIT_USER_EMAIL", opts.user_email.as_deref().unwrap_or("")),
148 ];
149
150 let parent = dir.parent().unwrap_or(Path::new("."));
152 let _ = std::fs::create_dir_all(parent);
153 let pre: Vec<String> = conf
154 .pre_clone
155 .0
156 .iter()
157 .chain(r.pre_clone.0.iter())
158 .cloned()
159 .collect();
160 if let Err(e) = run_hooks(&pre, parent, &env) {
161 println!("FAILED {name:<28} {e}");
162 return mk(Outcome::Failed(e));
163 }
164
165 println!("+ {command}");
167 let refs: Vec<&str> = args.iter().map(String::as_str).collect();
168 let out = git.run(Path::new("."), &refs);
169 if !out.success {
170 let e = out.stderr.trim().to_string();
171 println!("FAILED {name:<28} {}", e.lines().next().unwrap_or(""));
172 return mk(Outcome::Failed(e));
173 }
174
175 for (key, val) in [
178 ("user.name", opts.user_name.as_deref()),
179 ("user.email", opts.user_email.as_deref()),
180 ] {
181 if let Some(v) = val {
182 println!("+ git config {key} {v}");
183 let out = git.run(&dir, &["config", key, v]);
184 if !out.success {
185 let e = format!("git config {key} failed: {}", out.stderr.trim());
186 println!("FAILED {name:<28} {e}");
187 return mk(Outcome::Failed(e));
188 }
189 }
190 }
191 if opts.submodule_branch {
193 let _ = git.run(
194 &dir,
195 &["submodule", "foreach", "--recursive", SUBMODULE_SWITCH],
196 );
197 }
198 if opts.direnv && dir.join(".envrc").exists() {
199 let _ = Command::new("direnv").arg("allow").arg(&dir).output(); }
201
202 let post: Vec<String> = conf
204 .post_clone
205 .0
206 .iter()
207 .chain(r.post_clone.0.iter())
208 .cloned()
209 .collect();
210 if let Err(e) = run_hooks(&post, &dir, &env) {
211 println!("FAILED {name:<28} {e}");
212 return mk(Outcome::Failed(e));
213 }
214
215 println!("cloned {name:<28} {dir_s}");
216 mk(Outcome::Cloned)
217 })
218 .collect()
219}
220
221#[cfg(test)]
222mod tests {
223 use crate::conf;
224
225 #[test]
226 fn builds_expected_url_shape() {
227 let c = conf::parse("host = \"tlbb\"\nnamespace = \"example-org\"\n[[repo]]\ndir = \"$HOME/x/cosp\"\ndepth = 1\n").unwrap();
228 assert_eq!(c.repo[0].name(), "cosp");
229 assert_eq!(c.repo[0].depth, Some(1));
230 let ns = c.namespace_for(&c.repo[0]).unwrap();
231 let url = format!("{}:{}/{}.git", c.host, ns, c.repo[0].name());
232 assert_eq!(url, "tlbb:example-org/cosp.git");
233 }
234
235 #[test]
236 fn per_repo_namespace_drives_url() {
237 let c = conf::parse("host=\"gh\"\n[[repo]]\ndir=\"$HOME/x/foo\"\nnamespace=\"alice\"\n")
238 .unwrap();
239 let ns = c.namespace_for(&c.repo[0]).unwrap();
240 let url = format!("{}:{}/{}.git", c.host, ns, c.repo[0].name());
241 assert_eq!(url, "gh:alice/foo.git");
242 }
243}