Skip to main content

gkit_core/
conf.rs

1//! Clone config — structured TOML.
2//!
3//! ```toml
4//! host      = "tlbb"
5//! namespace = "example-org"   # GitHub org / GitLab group / user; URL = host:namespace/repo.git
6//!
7//! # global (all optional; `namespace` too — a repo may set its own instead)
8//! git-flags   = ["-c", "http.lowSpeedLimit=1000"]   # raw, BEFORE `clone`
9//! clone-flags = ["--filter=blob:none"]              # raw, AFTER `clone`
10//! pre-clone   = "echo starting $GKIT_REPO"           # string OR list of strings
11//! post-clone  = ["direnv allow ."]
12//!
13//! [[repo]]
14//! dir = "$CP_HOME/cp-conf"
15//!
16//! [[repo]]
17//! dir         = "$CP_COMMON_LIBS/cosp"
18//! namespace   = "other-org"   # overrides the global namespace for THIS repo
19//! depth       = 1
20//! branch      = "dev"               # full clone, checked out on dev
21//! # single-branch = true            # add to fetch ONLY `branch` (--single-branch)
22//! clone-flags = ["--no-tags"]
23//! post-clone  = ["mill compile"]
24//! ```
25//!
26//! `host` (and optionally `namespace`) live in the file (not the filename) → one
27//! ssh key can back many confs. A repo's effective namespace is its own
28//! `namespace`, else the global one; at least one must be present. gkit keeps no
29//! global state: this file + each repo's own metadata are the state.
30
31use serde::Deserialize;
32
33#[derive(Debug, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "kebab-case", deny_unknown_fields)]
35pub struct CloneConf {
36    pub host: String,
37    /// Global namespace (org/group/user). Optional — a repo may set its own; every
38    /// repo must resolve one (see [`CloneConf::validate`]).
39    #[serde(default)]
40    pub namespace: Option<String>,
41    /// Raw flags applied BEFORE `clone` (git-level, e.g. `-c k=v`).
42    #[serde(default)]
43    pub git_flags: Vec<String>,
44    /// Raw flags applied AFTER `clone` for every repo.
45    #[serde(default)]
46    pub clone_flags: Vec<String>,
47    /// Commands run before every repo's clone.
48    #[serde(default)]
49    pub pre_clone: Hooks,
50    /// Commands run after every repo's clone.
51    #[serde(default)]
52    pub post_clone: Hooks,
53    #[serde(default)]
54    pub repo: Vec<Repo>,
55}
56
57#[derive(Debug, Deserialize, PartialEq, Eq)]
58#[serde(rename_all = "kebab-case", deny_unknown_fields)]
59pub struct Repo {
60    /// Local destination dir (raw; `$VAR`/`~` expanded at clone time).
61    pub dir: String,
62    /// Per-repo namespace (org/group/user) — overrides the global `namespace` for
63    /// this repo. One of repo/global namespace must be set.
64    #[serde(default)]
65    pub namespace: Option<String>,
66    /// Remote repo name (the URL's last segment). Defaults to `basename(dir)`; set
67    /// this to clone a repo into a differently-named local directory.
68    #[serde(default)]
69    pub name: Option<String>,
70    #[serde(default)]
71    pub depth: Option<u32>,
72    /// Branch (or tag) to check out after clone (`--branch B`). By default this is a
73    /// FULL clone parked on `B` (all branches fetched); set `single-branch = true` to
74    /// fetch only `B`.
75    #[serde(default)]
76    pub branch: Option<String>,
77    /// Clone only one branch (`--single-branch`). Pairs with `branch` to fetch just
78    /// that branch; on its own (no `branch`) it clones only the remote's default
79    /// branch — same as bare `git clone --single-branch`.
80    #[serde(default)]
81    pub single_branch: bool,
82    /// Per-repo raw flags AFTER `clone`.
83    #[serde(default)]
84    pub clone_flags: Vec<String>,
85    #[serde(default)]
86    pub pre_clone: Hooks,
87    #[serde(default)]
88    pub post_clone: Hooks,
89}
90
91impl CloneConf {
92    /// Effective namespace for a repo: its own `namespace`, else the global one.
93    pub fn namespace_for<'a>(&'a self, repo: &'a Repo) -> Option<&'a str> {
94        repo.namespace.as_deref().or(self.namespace.as_deref())
95    }
96
97    /// Every repo must resolve a namespace (per-repo or global). Returns an error
98    /// naming the offending dir(s) — call before cloning so nothing runs when a
99    /// namespace is missing.
100    pub fn validate(&self) -> Result<(), String> {
101        let missing: Vec<&str> = self
102            .repo
103            .iter()
104            .filter(|r| self.namespace_for(r).is_none())
105            .map(|r| r.dir.as_str())
106            .collect();
107        if missing.is_empty() {
108            Ok(())
109        } else {
110            Err(format!(
111                "no namespace for {} — set a global `namespace` or a per-repo `namespace`",
112                missing.join(", ")
113            ))
114        }
115    }
116}
117
118impl Repo {
119    /// Remote repo name (drives the clone URL): explicit `name`, else basename(dir).
120    pub fn name(&self) -> String {
121        self.name.clone().unwrap_or_else(|| {
122            self.dir
123                .trim_end_matches('/')
124                .rsplit('/')
125                .next()
126                .unwrap_or(&self.dir)
127                .to_string()
128        })
129    }
130}
131
132/// A hook field: TOML may give a single string or a list of strings.
133#[derive(Debug, Default, PartialEq, Eq)]
134pub struct Hooks(pub Vec<String>);
135
136impl<'de> Deserialize<'de> for Hooks {
137    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
138        #[derive(Deserialize)]
139        #[serde(untagged)]
140        enum OneOrMany {
141            One(String),
142            Many(Vec<String>),
143        }
144        Ok(match OneOrMany::deserialize(d)? {
145            OneOrMany::One(s) => Hooks(vec![s]),
146            OneOrMany::Many(v) => Hooks(v),
147        })
148    }
149}
150
151/// Parse the TOML clone config.
152pub fn parse(text: &str) -> Result<CloneConf, String> {
153    toml::from_str(text).map_err(|e| e.message().to_string())
154}
155
156/// Expand a leading `~` and `$VAR`/`${VAR}` using `get` (e.g. `|k| std::env::var(k).ok()`).
157/// Unset variables expand to empty (like a shell).
158pub fn expand_path(raw: &str, get: impl Fn(&str) -> Option<String>) -> String {
159    let mut s = raw.to_string();
160    if s == "~" {
161        return get("HOME").unwrap_or_default();
162    }
163    if let Some(rest) = s.strip_prefix("~/") {
164        s = format!("{}/{}", get("HOME").unwrap_or_default(), rest);
165    }
166    expand_vars(&s, get)
167}
168
169fn expand_vars(s: &str, get: impl Fn(&str) -> Option<String>) -> String {
170    let bytes = s.as_bytes();
171    let mut out = String::with_capacity(s.len());
172    let mut i = 0;
173    while i < bytes.len() {
174        if bytes[i] == b'$' {
175            let (name, next) = if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
176                match s[i + 2..].find('}').map(|e| i + 2 + e) {
177                    Some(e) => (&s[i + 2..e], e + 1),
178                    None => (&s[i + 1..i + 1], i + 1),
179                }
180            } else {
181                let mut j = i + 1;
182                while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
183                    j += 1;
184                }
185                (&s[i + 1..j], j)
186            };
187            if name.is_empty() {
188                out.push('$');
189                i += 1;
190            } else {
191                out.push_str(&get(name).unwrap_or_default());
192                i = next;
193            }
194        } else {
195            out.push(bytes[i] as char);
196            i += 1;
197        }
198    }
199    out
200}
201
202/// Parse an scp-like `host:namespace/repo.git` URL into `(host, namespace)`.
203/// Returns `None` for `https://` or `user@host` forms (gkit uses ssh Host aliases),
204/// so `init` only pre-fills when it can do so cleanly.
205pub fn scp_url_parts(url: &str) -> Option<(String, String)> {
206    let url = url.trim();
207    if url.contains("://") || url.contains('@') {
208        return None;
209    }
210    let (host, path) = url.split_once(':')?;
211    let (namespace, _repo) = path.rsplit_once('/')?;
212    if host.is_empty() || namespace.is_empty() {
213        return None;
214    }
215    Some((host.to_string(), namespace.to_string()))
216}
217
218/// A starter clone config (sensible defaults + commented examples). `host`/
219/// `namespace` are pre-filled when known, else left as placeholders.
220pub fn template(host: Option<&str>, namespace: Option<&str>) -> String {
221    let host = host.unwrap_or("<ssh-host-alias>");
222    let namespace = namespace.unwrap_or("<namespace>");
223    format!(
224        r#"# gkit clone config — run `gkit clone <this-file>`.
225host      = "{host}"        # ssh Host alias (~/.ssh/config); URL = host:namespace/repo.git
226namespace = "{namespace}"   # GitHub org / GitLab group / user (optional — a repo may set its own)
227
228# `gkit.baseBranch` = this repo's integration branch. `gkit logoff` and `gkit stmb`
229# read it as the "base": the branch stmb returns to, and the one logoff checks
230# against. Stamped on every cloned repo here:
231post-clone = ["git config gkit.baseBranch main"]   # change to your convention: master / dev
232
233# More optional global settings (uncomment as needed):
234# git-flags   = ["-c", "http.lowSpeedLimit=1000"]   # raw flags BEFORE `clone`
235# clone-flags = ["--filter=blob:none"]              # raw flags AFTER `clone`
236# pre-clone   = "echo cloning $GKIT_REPO"
237
238# One [[repo]] block per repo (name = basename of dir; $VAR/~ expanded):
239[[repo]]
240dir = "$HOME/work/example"
241# namespace   = "other-org"   # override the global namespace for THIS repo
242# name        = "example"     # remote repo name if it differs from the dir basename
243# depth         = 1
244# branch        = "dev"     # check out dev (full clone — all branches fetched)
245# single-branch = true      # fetch ONLY `branch` (--single-branch); needs `branch` unless you want just the default branch
246# clone-flags   = ["--no-tags"]
247# post-clone  = ["mill compile"]
248"#
249    )
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use std::collections::HashMap;
256
257    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
258        let m: HashMap<String, String> = pairs
259            .iter()
260            .map(|(k, v)| (k.to_string(), v.to_string()))
261            .collect();
262        move |k| m.get(k).cloned()
263    }
264
265    #[test]
266    fn parses_minimal_toml() {
267        let c = parse(
268            "host = \"tlbb\"\nnamespace = \"example-org\"\n[[repo]]\ndir = \"$CP_HOME/cp-conf\"\n",
269        )
270        .unwrap();
271        assert_eq!(c.host, "tlbb");
272        assert_eq!(c.namespace.as_deref(), Some("example-org"));
273        assert_eq!(c.repo.len(), 1);
274        assert_eq!(c.repo[0].name(), "cp-conf");
275        assert!(c.git_flags.is_empty() && c.pre_clone.0.is_empty());
276    }
277
278    #[test]
279    fn parses_full_toml_with_hooks_and_flags() {
280        let c = parse(
281            r#"
282host = "tlbb"
283namespace = "example-org"
284git-flags = ["-c", "http.x=y"]
285clone-flags = ["--filter=blob:none"]
286pre-clone = "echo global pre"
287post-clone = ["direnv allow ."]
288
289[[repo]]
290dir = "$D/cosp"
291depth = 1
292branch = "dev"
293clone-flags = ["--no-tags"]
294post-clone = ["mill compile", "echo done"]
295"#,
296        )
297        .unwrap();
298        assert_eq!(c.git_flags, ["-c", "http.x=y"]); // PRE
299        assert_eq!(c.clone_flags, ["--filter=blob:none"]); // POST global
300        assert_eq!(c.pre_clone.0, ["echo global pre"]); // string -> 1-elem list
301        assert_eq!(c.post_clone.0, ["direnv allow ."]);
302        let r = &c.repo[0];
303        assert_eq!(r.depth, Some(1));
304        assert_eq!(r.branch.as_deref(), Some("dev"));
305        assert!(!r.single_branch); // defaults off → full clone on `branch`
306        assert_eq!(r.clone_flags, ["--no-tags"]);
307        assert_eq!(r.post_clone.0, ["mill compile", "echo done"]); // list kept
308    }
309
310    #[test]
311    fn parses_single_branch_flag() {
312        let c = parse(
313            "host=\"h\"\nnamespace=\"o\"\n[[repo]]\ndir=\"$H/r\"\nbranch=\"dev\"\nsingle-branch=true\n",
314        )
315        .unwrap();
316        assert_eq!(c.repo[0].branch.as_deref(), Some("dev"));
317        assert!(c.repo[0].single_branch);
318    }
319
320    #[test]
321    fn name_overrides_basename_for_url() {
322        // clone the remote repo `cosp` into a differently-named local dir
323        let c = parse(
324            "host=\"h\"\nnamespace=\"o\"\n[[repo]]\ndir=\"$HOME/work/my-cosp\"\nname=\"cosp\"\n",
325        )
326        .unwrap();
327        assert_eq!(c.repo[0].name(), "cosp"); // URL uses `cosp`, dir is `my-cosp`
328                                              // default (no name) still uses basename
329        let d =
330            parse("host=\"h\"\nnamespace=\"o\"\n[[repo]]\ndir=\"$HOME/work/my-cosp\"\n").unwrap();
331        assert_eq!(d.repo[0].name(), "my-cosp");
332    }
333
334    #[test]
335    fn requires_host() {
336        // host is required by serde; missing it is a parse error.
337        assert!(parse("namespace = \"o\"\n").unwrap_err().contains("host"));
338    }
339
340    #[test]
341    fn namespace_optional_at_parse() {
342        // global namespace is now optional — host alone parses (validation is
343        // separate and per-repo).
344        let c = parse("host = \"h\"\n").unwrap();
345        assert_eq!(c.namespace, None);
346        assert!(c.validate().is_ok()); // no repos -> nothing to resolve
347    }
348
349    #[test]
350    fn per_repo_namespace_overrides_global() {
351        let c = parse(
352            "host=\"gh\"\nnamespace=\"glob\"\n[[repo]]\ndir=\"$H/a\"\n[[repo]]\ndir=\"$H/b\"\nnamespace=\"bob\"\n",
353        )
354        .unwrap();
355        assert_eq!(c.namespace_for(&c.repo[0]), Some("glob")); // falls back to global
356        assert_eq!(c.namespace_for(&c.repo[1]), Some("bob")); // per-repo wins
357    }
358
359    #[test]
360    fn validate_ok_with_per_repo_namespace_no_global() {
361        // no global namespace, but each repo supplies its own -> valid
362        let c = parse(
363            "host=\"gh\"\n[[repo]]\ndir=\"$H/a\"\nnamespace=\"alice\"\n[[repo]]\ndir=\"$H/b\"\nnamespace=\"bob\"\n",
364        )
365        .unwrap();
366        assert!(c.validate().is_ok());
367        assert_eq!(c.namespace_for(&c.repo[0]), Some("alice"));
368    }
369
370    #[test]
371    fn validate_errors_when_no_namespace() {
372        // no global, and this repo has none -> validate names the offending dir
373        let c = parse("host=\"gh\"\n[[repo]]\ndir=\"$H/lonely\"\n").unwrap();
374        let err = c.validate().unwrap_err();
375        assert!(err.contains("$H/lonely"), "names the dir: {err}");
376        assert!(err.contains("namespace"));
377    }
378
379    #[test]
380    fn rejects_unknown_field() {
381        assert!(parse("host=\"h\"\nnamespace=\"o\"\nbogus=1\n").is_err());
382    }
383
384    #[test]
385    fn scp_url_parses_alias_form_only() {
386        assert_eq!(
387            scp_url_parts("tlbb:example-org/cosp.git"),
388            Some(("tlbb".into(), "example-org".into()))
389        );
390        assert_eq!(
391            scp_url_parts("ctl:grp/sub/repo.git"),
392            Some(("ctl".into(), "grp/sub".into()))
393        ); // gitlab subgroup
394        assert_eq!(scp_url_parts("git@github.com:org/repo.git"), None); // user@ form -> skip
395        assert_eq!(scp_url_parts("https://github.com/org/repo.git"), None); // https -> skip
396        assert_eq!(scp_url_parts("tlbb:noslash"), None);
397    }
398
399    #[test]
400    fn rejects_solo_field() {
401        // `solo` is no longer a conf key — it's set manually via `git config
402        // gkit.solo`. A leftover `solo =` must be a hard parse error.
403        assert!(parse("host=\"h\"\nnamespace=\"o\"\nsolo=true\n").is_err());
404    }
405
406    #[test]
407    fn template_fills_or_placeholders() {
408        let filled = template(Some("tlbb"), Some("example-org"));
409        assert!(filled.contains("host      = \"tlbb\""));
410        assert!(filled.contains("namespace = \"example-org\""));
411        assert!(filled.contains("[[repo]]"));
412        assert!(filled.contains(r#"post-clone = ["git config gkit.baseBranch main"]"#));
413        let blank = template(None, None);
414        assert!(blank.contains("<ssh-host-alias>") && blank.contains("<namespace>"));
415        // the template must itself be valid TOML that parses
416        assert!(parse(&filled).is_ok());
417    }
418
419    #[test]
420    fn expands_home_and_vars() {
421        let get = env(&[("HOME", "/h"), ("CP_HOME", "/c"), ("X", "/x")]);
422        assert_eq!(expand_path("~/foo", &get), "/h/foo");
423        assert_eq!(expand_path("$CP_HOME/cp-conf", &get), "/c/cp-conf");
424        assert_eq!(expand_path("${X}/b", &get), "/x/b");
425        assert_eq!(expand_path("/abs", &get), "/abs");
426        assert_eq!(expand_path("$UNSET/y", &get), "/y");
427    }
428}