Skip to main content

gkit_core/
stamp.rs

1//! Re-apply a clone conf's `post-clone` hooks over an **existing** tree, without
2//! cloning. `gkit clone` runs `post-clone` once, right after cloning; `gkit stamp`
3//! re-runs the same hooks on repos that are already on disk.
4//!
5//! Why this exists: `post-clone` is where teams stamp per-repo git config the gate
6//! reads — `git config gkit.baseBranch …`, `git config gkit.solo …`, usually with a
7//! `git submodule foreach --recursive '…'` so submodules get it too. But that runs
8//! only at clone time, so a submodule **added later** (e.g. on a feature branch that
9//! pins a new submodule) is never stamped: it comes up with no `gkit.baseBranch`
10//! (base falls back to `origin/main`/`master`) and no `gkit.solo` (the team rule).
11//! `gkit stamp <conf>` re-runs the conf's `post-clone` over the existing repos so
12//! those values converge — idempotently, since they're `git config` writes.
13//!
14//! `stamp` does **not** clone, fetch, or run `pre-clone`/built-ins. Identity
15//! (`--user-name`/`--user-email`) is a `clone` concern, so the `$GKIT_USER_NAME`/
16//! `$GKIT_USER_EMAIL` hook env is empty here; the other `$GKIT_*` vars mirror clone.
17
18use crate::clone::run_hooks;
19use crate::conf::{expand_path, CloneConf, Repo};
20use crate::config;
21use crate::git::Git;
22use std::path::{Path, PathBuf};
23
24#[derive(Debug, PartialEq, Eq)]
25pub enum Outcome {
26    /// `post-clone` hooks ran successfully over the repo.
27    Stamped,
28    /// Nothing to do (the conf has no `post-clone` for this repo).
29    Skipped,
30    /// The dir is missing / not a git repo, or a hook failed.
31    Failed(String),
32}
33
34#[derive(Debug)]
35pub struct StampReport {
36    pub name: String,
37    pub dir: PathBuf,
38    pub outcome: Outcome,
39}
40
41/// The effective `post-clone` hooks for a repo: the global ones, then the repo's
42/// own (same order `clone` runs them). Shared with the CLI's dry-run plan.
43pub fn effective_post_clone(conf: &CloneConf, repo: &Repo) -> Vec<String> {
44    conf.post_clone
45        .0
46        .iter()
47        .chain(repo.post_clone.0.iter())
48        .cloned()
49        .collect()
50}
51
52/// Is `dir` inside a git work tree? (Same probe as the gate uses for the root.)
53fn is_git_repo(git: &dyn Git, dir: &Path) -> bool {
54    let r = git.run(dir, &["rev-parse", "--is-inside-work-tree"]);
55    r.success && r.trimmed() == "true"
56}
57
58/// Re-run each repo's effective `post-clone` over its existing dir, printing each
59/// step in order. Returns a report per repo (for the aggregate exit code).
60///
61/// Per repo: a missing dir or non-repo **fails** (we want to know — never a silent
62/// skip); a repo with no `post-clone` is skipped; otherwise the hooks run with the
63/// same `$GKIT_*` env `clone` sets (identity vars empty — see module docs).
64pub fn stamp_all<G: Git>(git: &G, conf: &CloneConf) -> Vec<StampReport> {
65    conf.repo
66        .iter()
67        .map(|r| {
68            let name = r.name();
69            let dir_s = expand_path(&r.dir, |k| std::env::var(k).ok());
70            let dir = PathBuf::from(&dir_s);
71            let mk = |outcome| StampReport {
72                name: name.clone(),
73                dir: dir.clone(),
74                outcome,
75            };
76
77            // Must be an existing git repo — otherwise there's nothing to stamp and a
78            // silent pass would hide a missing/foreign dir.
79            if !dir.exists() {
80                let e = "no such directory".to_string();
81                println!("FAILED   {name:<28} {dir_s} ({e})");
82                return mk(Outcome::Failed(e));
83            }
84            if !is_git_repo(git, &dir) {
85                let e = "not a git repository".to_string();
86                println!("FAILED   {name:<28} {dir_s} ({e})");
87                return mk(Outcome::Failed(e));
88            }
89
90            let post = effective_post_clone(conf, r);
91            if post.is_empty() {
92                println!("skipped  {name:<28} {dir_s} (no post-clone hooks)");
93                return mk(Outcome::Skipped);
94            }
95
96            // The `$GKIT_*` hook env, mirroring clone. Namespace is only needed to
97            // build `$GKIT_URL`; if it can't resolve (validated away by the CLI) we
98            // still stamp — the config hooks don't depend on it.
99            let ns = conf.namespace_for(r).unwrap_or_default().to_string();
100            let url = format!("{}:{}/{}.git", conf.host, ns, name);
101            let env = [
102                ("GKIT_REPO", name.as_str()),
103                ("GKIT_DIR", dir_s.as_str()),
104                ("GKIT_URL", url.as_str()),
105                ("GKIT_HOST", conf.host.as_str()),
106                ("GKIT_NAMESPACE", ns.as_str()),
107                ("GKIT_USER_NAME", ""),
108                ("GKIT_USER_EMAIL", ""),
109            ];
110
111            if let Err(e) = run_hooks(&post, &dir, &env) {
112                println!("FAILED   {name:<28} {e}");
113                return mk(Outcome::Failed(e));
114            }
115            println!("stamped  {name:<28} {dir_s}");
116            mk(Outcome::Stamped)
117        })
118        .collect()
119}
120
121/// Find the `[[repo]]` whose `expand_path(dir)` canonicalizes to `repo_dir`.
122/// Matching on canonicalized paths absorbs `$VAR`/`~` expansion, symlinks, and
123/// trailing-slash differences. `repo_dir` must already be canonicalized.
124pub fn match_repo<'a>(conf: &'a CloneConf, repo_dir: &Path) -> Option<&'a Repo> {
125    conf.repo.iter().find(|r| {
126        let d = expand_path(&r.dir, |k| std::env::var(k).ok());
127        std::fs::canonicalize(&d)
128            .map(|c| c == *repo_dir)
129            .unwrap_or(false)
130    })
131}
132
133/// What a repo-mode stamp resolved: the conf it came from, the hooks to run, whether
134/// a `[[repo]]` matched (false → global `post-clone` only), and the `$GKIT_*` env
135/// bits (empty when unmatched).
136pub struct RepoPlan {
137    pub conf_path: String,
138    pub hooks: Vec<String>,
139    pub matched: bool,
140    pub env_repo: String,
141    pub env_url: String,
142    pub env_host: String,
143    pub env_namespace: String,
144}
145
146/// Resolve a repo's own `gkit.conf`, parse it, and compute the hooks to run in this
147/// repo (the matched `[[repo]]`'s effective post-clone, else the conf's global
148/// post-clone). `Err` when `gkit.conf` is unset (actionable) or the conf can't be
149/// read/parsed. `repo_dir` must already be canonicalized by the caller.
150pub fn plan_repo<G: Git>(git: &G, repo_dir: &Path) -> Result<RepoPlan, String> {
151    let conf_path = config::resolve_conf(git, repo_dir).ok_or_else(|| {
152        format!(
153            "gkit.conf not set in {}; run `gkit stamp --conf <conf>` once to back-fill, or pass the conf",
154            repo_dir.display()
155        )
156    })?;
157    let text = std::fs::read_to_string(&conf_path)
158        .map_err(|e| format!("cannot read gkit.conf `{conf_path}`: {e}"))?;
159    let cfg = crate::conf::parse(&text).map_err(|e| format!("{conf_path}: {e}"))?;
160
161    let basename = repo_dir
162        .file_name()
163        .map(|s| s.to_string_lossy().into_owned())
164        .unwrap_or_else(|| repo_dir.display().to_string());
165    match match_repo(&cfg, repo_dir) {
166        Some(r) => {
167            let host = cfg.host.clone();
168            let ns = cfg.namespace_for(r).unwrap_or_default().to_string();
169            let repo = r.name();
170            let url = format!("{host}:{ns}/{repo}.git");
171            Ok(RepoPlan {
172                conf_path,
173                hooks: effective_post_clone(&cfg, r),
174                matched: true,
175                env_repo: repo,
176                env_url: url,
177                env_host: host,
178                env_namespace: ns,
179            })
180        }
181        None => Ok(RepoPlan {
182            conf_path,
183            hooks: cfg.post_clone.0.clone(),
184            matched: false,
185            env_repo: basename,
186            env_url: String::new(),
187            env_host: String::new(),
188            env_namespace: String::new(),
189        }),
190    }
191}
192
193/// Repo-mode stamp: resolve the repo's own `gkit.conf` ([`plan_repo`]) and run its
194/// hooks in `repo_dir`, with the same `$GKIT_*` env shape `clone`/`stamp_all` use
195/// (identity vars empty — identity is a `clone`/`fixsub` concern). `repo_dir` must
196/// already be canonicalized.
197pub fn stamp_repo<G: Git>(git: &G, repo_dir: &Path) -> StampReport {
198    let dir_s = repo_dir.display().to_string();
199    let basename = repo_dir
200        .file_name()
201        .map(|s| s.to_string_lossy().into_owned())
202        .unwrap_or_else(|| dir_s.clone());
203    let mk = |name: &str, outcome| StampReport {
204        name: name.to_string(),
205        dir: repo_dir.to_path_buf(),
206        outcome,
207    };
208
209    if !is_git_repo(git, repo_dir) {
210        let e = "not a git repository".to_string();
211        println!("FAILED   {basename:<28} {dir_s} ({e})");
212        return mk(&basename, Outcome::Failed(e));
213    }
214    let plan = match plan_repo(git, repo_dir) {
215        Ok(p) => p,
216        Err(e) => {
217            println!("FAILED   {basename:<28} {e}");
218            return mk(&basename, Outcome::Failed(e));
219        }
220    };
221    if !plan.matched {
222        println!(
223            "note: {dir_s} not listed in {} — running global post-clone only",
224            plan.conf_path
225        );
226    }
227    if plan.hooks.is_empty() {
228        println!(
229            "skipped  {:<28} {dir_s} (no post-clone hooks)",
230            plan.env_repo
231        );
232        return mk(&plan.env_repo, Outcome::Skipped);
233    }
234    let env = [
235        ("GKIT_REPO", plan.env_repo.as_str()),
236        ("GKIT_DIR", dir_s.as_str()),
237        ("GKIT_URL", plan.env_url.as_str()),
238        ("GKIT_HOST", plan.env_host.as_str()),
239        ("GKIT_NAMESPACE", plan.env_namespace.as_str()),
240        ("GKIT_USER_NAME", ""),
241        ("GKIT_USER_EMAIL", ""),
242    ];
243    if let Err(e) = run_hooks(&plan.hooks, repo_dir, &env) {
244        println!("FAILED   {:<28} {e}", plan.env_repo);
245        return mk(&plan.env_repo, Outcome::Failed(e));
246    }
247    println!("stamped  {:<28} {dir_s}", plan.env_repo);
248    mk(&plan.env_repo, Outcome::Stamped)
249}
250
251/// Conf-mode back-fill: set `gkit.conf` (the absolute conf path) on each `[[repo]]`
252/// that is a git repo and lacks it. **Never overwrites** an existing value (one-way
253/// migration; idempotent). Prints each `git config` it runs. Non-fatal — a set
254/// failure is warned but doesn't fail the run (the post-clone stamp is the job).
255pub fn backfill_conf<G: Git>(git: &G, conf: &CloneConf, abs_conf_path: &str) {
256    for r in &conf.repo {
257        let dir_s = expand_path(&r.dir, |k| std::env::var(k).ok());
258        let dir = PathBuf::from(&dir_s);
259        if !dir.exists() || !is_git_repo(git, &dir) {
260            continue;
261        }
262        if config::resolve_conf(git, &dir).is_none() {
263            println!("+ git config gkit.conf {abs_conf_path}  ({dir_s})");
264            let out = git.run(&dir, &["config", "gkit.conf", abs_conf_path]);
265            if !out.success {
266                println!(
267                    "warning: could not set gkit.conf in {dir_s}: {}",
268                    out.stderr.trim()
269                );
270            }
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::conf::{Hooks, Repo};
279    use crate::git::test_support::FakeGit;
280
281    fn repo(dir: &str, post: &[&str]) -> Repo {
282        Repo {
283            dir: dir.to_string(),
284            namespace: None,
285            name: None,
286            depth: None,
287            branch: None,
288            single_branch: false,
289            clone_flags: vec![],
290            pre_clone: Hooks(vec![]),
291            post_clone: Hooks(post.iter().map(|s| s.to_string()).collect()),
292        }
293    }
294
295    fn conf(global_post: &[&str], repos: Vec<Repo>) -> CloneConf {
296        CloneConf {
297            host: "h".into(),
298            namespace: Some("ns".into()),
299            git_flags: vec![],
300            clone_flags: vec![],
301            pre_clone: Hooks(vec![]),
302            post_clone: Hooks(global_post.iter().map(|s| s.to_string()).collect()),
303            repo: repos,
304        }
305    }
306
307    #[test]
308    fn effective_post_clone_chains_global_then_repo() {
309        let c = conf(
310            &["git config gkit.solo true"],
311            vec![repo("/x", &["git config gkit.baseBranch dev"])],
312        );
313        assert_eq!(
314            effective_post_clone(&c, &c.repo[0]),
315            [
316                "git config gkit.solo true",
317                "git config gkit.baseBranch dev"
318            ]
319        );
320    }
321
322    #[test]
323    fn match_repo_by_canonical_dir() {
324        // A [[repo]] dir matches when it canonicalizes to the queried repo path; a
325        // dir not listed in the conf returns None.
326        let base = std::env::temp_dir().join(format!("gkit-match-{}", std::process::id()));
327        let _ = std::fs::remove_dir_all(&base);
328        let a = base.join("a");
329        let b = base.join("b");
330        std::fs::create_dir_all(&a).unwrap();
331        std::fs::create_dir_all(&b).unwrap();
332        let c = conf(&[], vec![repo(a.to_str().unwrap(), &[])]);
333        let a_canon = std::fs::canonicalize(&a).unwrap();
334        let b_canon = std::fs::canonicalize(&b).unwrap();
335        assert!(match_repo(&c, &a_canon).is_some(), "listed dir matches");
336        assert!(match_repo(&c, &b_canon).is_none(), "unlisted dir → None");
337        let _ = std::fs::remove_dir_all(&base);
338    }
339
340    #[test]
341    fn missing_dir_is_failed() {
342        // A dir that doesn't exist must FAIL (not silently pass) — no FakeGit response
343        // is needed because `dir.exists()` short-circuits before any git call.
344        let c = conf(
345            &["git config gkit.solo true"],
346            vec![repo("/no/such/gkit-stamp-xyz", &[])],
347        );
348        let reports = stamp_all(&FakeGit::new(), &c);
349        assert_eq!(reports.len(), 1);
350        assert!(matches!(reports[0].outcome, Outcome::Failed(ref e) if e.contains("no such")));
351    }
352}