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 (submodule branch-switch + `direnv allow`) →
6//! global `post-clone` → repo `post-clone`.
7//!
8//! The `git clone` and built-ins are **captured** (clean status; an `.envrc` that
9//! runs `glow …` can't distort output — `direnv allow` only records trust). User
10//! hooks run via `sh -c` with their output **inherited** (explicit commands, shown
11//! live) and `$GKIT_REPO`/`GKIT_DIR`/`GKIT_URL`/`GKIT_HOST`/`GKIT_NAMESPACE` set.
12
13use crate::conf::{expand_path, CloneConf};
14use crate::git::Git;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18#[derive(Debug, PartialEq, Eq)]
19pub enum Outcome {
20    Cloned,
21    Skipped,
22    Failed(String),
23}
24
25#[derive(Debug)]
26pub struct CloneReport {
27    pub name: String,
28    pub dir: PathBuf,
29    pub outcome: Outcome,
30    pub command: String,
31}
32
33pub struct Opts {
34    pub submodule_branch: bool,
35    pub direnv: bool,
36}
37
38impl Default for Opts {
39    fn default() -> Self {
40        Self {
41            submodule_branch: true,
42            direnv: true,
43        }
44    }
45}
46
47const SUBMODULE_SWITCH: &str = "b=$(git config -f \"$toplevel/.gitmodules\" \"submodule.$name.branch\" 2>/dev/null || echo main); git switch \"$b\" 2>/dev/null || true";
48
49/// Run hook commands via `sh -c` in `cwd` with `env` set; output inherited; each
50/// printed `+ <cmd>`. Stops at the first non-zero exit.
51fn run_hooks(cmds: &[String], cwd: &Path, env: &[(&str, &str)]) -> Result<(), String> {
52    for cmd in cmds {
53        println!("+ {cmd}");
54        let mut c = Command::new("sh");
55        c.arg("-c").arg(cmd).current_dir(cwd);
56        for (k, v) in env {
57            c.env(k, v);
58        }
59        match c.status() {
60            Ok(s) if s.success() => {}
61            Ok(s) => return Err(format!("hook `{cmd}` exited {}", s.code().unwrap_or(-1))),
62            Err(e) => return Err(format!("hook `{cmd}` failed to start: {e}")),
63        }
64    }
65    Ok(())
66}
67
68/// Clone every repo in `conf`, printing each step in order. Returns a report per
69/// repo (for the aggregate exit code).
70pub fn clone_all<G: Git>(git: &G, conf: &CloneConf, opts: &Opts) -> Vec<CloneReport> {
71    conf.repo
72        .iter()
73        .map(|r| {
74            let name = r.name();
75            let dir_s = expand_path(&r.dir, |k| std::env::var(k).ok());
76            let dir = PathBuf::from(&dir_s);
77            // Per-repo namespace overrides the global one; `clone_cmd` validates this
78            // up front, so `None` here is a defensive backstop, not a normal path.
79            let ns = match conf.namespace_for(r) {
80                Some(n) => n.to_string(),
81                None => {
82                    let e = format!("no namespace for {}", r.dir);
83                    println!("FAILED   {name:<28} {e}");
84                    return CloneReport {
85                        name,
86                        dir,
87                        outcome: Outcome::Failed(e),
88                        command: String::new(),
89                    };
90                }
91            };
92            let url = format!("{}:{}/{}.git", conf.host, ns, name);
93
94            // git <git-flags> clone <depth/branch> --recurse-submodules <clone-flags> <repo flags> <url> <dir>
95            let mut args: Vec<String> = Vec::new();
96            args.extend(conf.git_flags.iter().cloned());
97            args.push("clone".into());
98            if let Some(d) = r.depth {
99                args.push("--depth".into());
100                args.push(d.to_string());
101            }
102            if let Some(b) = &r.branch {
103                args.push("--branch".into());
104                args.push(b.clone());
105                args.push("--single-branch".into());
106            }
107            args.push("--recurse-submodules".into());
108            args.extend(conf.clone_flags.iter().cloned());
109            args.extend(r.clone_flags.iter().cloned());
110            args.push(url.clone());
111            args.push(dir_s.clone());
112            let command = format!("git {}", args.join(" "));
113
114            let mk = |outcome| CloneReport {
115                name: name.clone(),
116                dir: dir.clone(),
117                outcome,
118                command: command.clone(),
119            };
120
121            if dir.join(".git").exists() {
122                println!("+ {command}");
123                println!("skipped  {name:<28} {dir_s} (exists)");
124                return mk(Outcome::Skipped);
125            }
126
127            let env = [
128                ("GKIT_REPO", name.as_str()),
129                ("GKIT_DIR", dir_s.as_str()),
130                ("GKIT_URL", url.as_str()),
131                ("GKIT_HOST", conf.host.as_str()),
132                ("GKIT_NAMESPACE", ns.as_str()),
133            ];
134
135            // 1+2: pre-clone hooks (cwd = parent of target; create it first)
136            let parent = dir.parent().unwrap_or(Path::new("."));
137            let _ = std::fs::create_dir_all(parent);
138            let pre: Vec<String> = conf
139                .pre_clone
140                .0
141                .iter()
142                .chain(r.pre_clone.0.iter())
143                .cloned()
144                .collect();
145            if let Err(e) = run_hooks(&pre, parent, &env) {
146                println!("FAILED   {name:<28} {e}");
147                return mk(Outcome::Failed(e));
148            }
149
150            // 3: clone (printed; output captured)
151            println!("+ {command}");
152            let refs: Vec<&str> = args.iter().map(String::as_str).collect();
153            let out = git.run(Path::new("."), &refs);
154            if !out.success {
155                let e = out.stderr.trim().to_string();
156                println!("FAILED   {name:<28} {}", e.lines().next().unwrap_or(""));
157                return mk(Outcome::Failed(e));
158            }
159
160            // 4: built-ins (captured)
161            if opts.submodule_branch {
162                let _ = git.run(
163                    &dir,
164                    &["submodule", "foreach", "--recursive", SUBMODULE_SWITCH],
165                );
166            }
167            if opts.direnv && dir.join(".envrc").exists() {
168                let _ = Command::new("direnv").arg("allow").arg(&dir).output(); // trust-only, no eval
169            }
170
171            // 5+6: post-clone hooks (cwd = the cloned repo)
172            let post: Vec<String> = conf
173                .post_clone
174                .0
175                .iter()
176                .chain(r.post_clone.0.iter())
177                .cloned()
178                .collect();
179            if let Err(e) = run_hooks(&post, &dir, &env) {
180                println!("FAILED   {name:<28} {e}");
181                return mk(Outcome::Failed(e));
182            }
183
184            println!("cloned   {name:<28} {dir_s}");
185            mk(Outcome::Cloned)
186        })
187        .collect()
188}
189
190#[cfg(test)]
191mod tests {
192    use crate::conf;
193
194    #[test]
195    fn builds_expected_url_shape() {
196        let c = conf::parse("host = \"tlbb\"\nnamespace = \"example-org\"\n[[repo]]\ndir = \"$HOME/x/cosp\"\ndepth = 1\n").unwrap();
197        assert_eq!(c.repo[0].name(), "cosp");
198        assert_eq!(c.repo[0].depth, Some(1));
199        let ns = c.namespace_for(&c.repo[0]).unwrap();
200        let url = format!("{}:{}/{}.git", c.host, ns, c.repo[0].name());
201        assert_eq!(url, "tlbb:example-org/cosp.git");
202    }
203
204    #[test]
205    fn per_repo_namespace_drives_url() {
206        let c = conf::parse("host=\"gh\"\n[[repo]]\ndir=\"$HOME/x/foo\"\nnamespace=\"alice\"\n")
207            .unwrap();
208        let ns = c.namespace_for(&c.repo[0]).unwrap();
209        let url = format!("{}:{}/{}.git", c.host, ns, c.repo[0].name());
210        assert_eq!(url, "gh:alice/foo.git");
211    }
212}