Skip to main content

gkit_core/
clone.rs

1//! Config-driven clone with explicit flag placement, built-in stateless steps, and
2//! pre/post-clone hooks.
3//!
4//! Per repo, in order: global `pre-clone` → repo `pre-clone` → `git <PRE> clone
5//! <POST> <url> <dir>` → built-ins (git identity + submodule branch-switch +
6//! `direnv allow`) → global `post-clone` → repo `post-clone`.
7//!
8//! Git identity (`user.name`/`user.email`) is **per-invocation, not in the conf**
9//! (the conf is shared across a team): it comes from `Opts` (the `clone`
10//! `--user-name`/`--user-email` flags, or an interactive prompt), and is stamped
11//! `git config` on each cloned repo right after clone so `post-clone` hooks see it.
12//!
13//! The `git clone` and built-ins are **captured** (clean status; an `.envrc` that
14//! runs `glow …` can't distort output — `direnv allow` only records trust). User
15//! hooks run via `sh -c` with their output **inherited** (explicit commands, shown
16//! live) and `$GKIT_REPO`/`GKIT_DIR`/`GKIT_URL`/`GKIT_HOST`/`GKIT_NAMESPACE` set
17//! (plus `GKIT_USER_NAME`/`GKIT_USER_EMAIL`, empty when no identity was given).
18
19use 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    /// Git identity stamped on each cloned repo (`git config user.name`). Per
43    /// invocation, not from the conf — `None` leaves the repo's inherited identity.
44    pub user_name: Option<String>,
45    /// Git identity stamped on each cloned repo (`git config user.email`).
46    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
62/// Single-quote a value for safe interpolation into an `sh -c` command line
63/// (each embedded `'` becomes `'\''`).
64fn sh_squote(s: &str) -> String {
65    format!("'{}'", s.replace('\'', "'\\''"))
66}
67
68/// The `git submodule foreach --recursive` body that stamps the resolved identity
69/// into each submodule, values single-quoted for `sh`. `None` when no identity was
70/// given (so the caller skips the recursion entirely).
71fn 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
79/// Run hook commands via `sh -c` in `cwd` with `env` set; output inherited; each
80/// printed `+ <cmd>`. Stops at the first non-zero exit. Shared with `stamp`, which
81/// re-runs a conf's `post-clone` over an existing tree.
82pub(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
99/// Clone every repo in `conf`, printing each step in order. Returns a report per
100/// repo (for the aggregate exit code).
101pub 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            // Per-repo namespace overrides the global one; `clone_cmd` validates this
109            // up front, so `None` here is a defensive backstop, not a normal path.
110            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            // git <git-flags> clone <depth/branch> --recurse-submodules <clone-flags> <repo flags> <url> <dir>
126            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            // 1+2: pre-clone hooks (cwd = parent of target; create it first)
169            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            // 3: clone (printed; output captured)
184            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            // 4: built-ins. Identity first (printed; values are explicit user input)
194            // so post-clone hooks and direnv see it; a failure fails the repo.
195            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            // 4a: the superproject (args passed straight to git — no shell).
203            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            // 4b: the same identity into every submodule (recursive) so commits there
213            // use it too — a submodule is its own repo with its own config. Runs via
214            // `sh -c`, so the values are single-quoted.
215            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            // remaining built-ins (captured)
230            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(); // trust-only, no eval
238            }
239
240            // 5+6: post-clone hooks (cwd = the cloned repo)
241            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        // both fields → two `git config`s, single-quoted, joined with `; `
267        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        // only one field set → just that one
272        assert_eq!(
273            submodule_identity_cmd(Some("Jane"), None).as_deref(),
274            Some("git config user.name 'Jane'")
275        );
276        // neither → None (caller skips the recursion)
277        assert_eq!(submodule_identity_cmd(None, None), None);
278        // an embedded single quote is escaped so `sh` can't break out
279        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}