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/// Run hook commands via `sh -c` in `cwd` with `env` set; output inherited; each
63/// printed `+ <cmd>`. Stops at the first non-zero exit.
64fn 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
81/// Clone every repo in `conf`, printing each step in order. Returns a report per
82/// repo (for the aggregate exit code).
83pub 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            // Per-repo namespace overrides the global one; `clone_cmd` validates this
91            // up front, so `None` here is a defensive backstop, not a normal path.
92            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            // git <git-flags> clone <depth/branch> --recurse-submodules <clone-flags> <repo flags> <url> <dir>
108            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            // 1+2: pre-clone hooks (cwd = parent of target; create it first)
151            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            // 3: clone (printed; output captured)
166            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            // 4: built-ins. Identity first (printed; values are explicit user input)
176            // so post-clone hooks and direnv see it; a failure fails the repo.
177            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            // remaining built-ins (captured)
192            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(); // trust-only, no eval
200            }
201
202            // 5+6: post-clone hooks (cwd = the cloned repo)
203            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}