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