1use 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 #[serde(default)]
39 pub namespace: Option<String>,
40 #[serde(default)]
42 pub git_flags: Vec<String>,
43 #[serde(default)]
45 pub clone_flags: Vec<String>,
46 #[serde(default)]
48 pub pre_clone: Hooks,
49 #[serde(default)]
51 pub post_clone: Hooks,
52 #[serde(default)]
53 pub repo: Vec<Repo>,
54}
55
56#[derive(Debug, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "kebab-case", deny_unknown_fields)]
58pub struct Repo {
59 pub dir: String,
61 #[serde(default)]
64 pub namespace: Option<String>,
65 #[serde(default)]
68 pub name: Option<String>,
69 #[serde(default)]
70 pub depth: Option<u32>,
71 #[serde(default)]
72 pub branch: Option<String>,
73 #[serde(default)]
75 pub clone_flags: Vec<String>,
76 #[serde(default)]
77 pub pre_clone: Hooks,
78 #[serde(default)]
79 pub post_clone: Hooks,
80}
81
82impl CloneConf {
83 pub fn namespace_for<'a>(&'a self, repo: &'a Repo) -> Option<&'a str> {
85 repo.namespace.as_deref().or(self.namespace.as_deref())
86 }
87
88 pub fn validate(&self) -> Result<(), String> {
92 let missing: Vec<&str> = self
93 .repo
94 .iter()
95 .filter(|r| self.namespace_for(r).is_none())
96 .map(|r| r.dir.as_str())
97 .collect();
98 if missing.is_empty() {
99 Ok(())
100 } else {
101 Err(format!(
102 "no namespace for {} — set a global `namespace` or a per-repo `namespace`",
103 missing.join(", ")
104 ))
105 }
106 }
107}
108
109impl Repo {
110 pub fn name(&self) -> String {
112 self.name.clone().unwrap_or_else(|| {
113 self.dir
114 .trim_end_matches('/')
115 .rsplit('/')
116 .next()
117 .unwrap_or(&self.dir)
118 .to_string()
119 })
120 }
121}
122
123#[derive(Debug, Default, PartialEq, Eq)]
125pub struct Hooks(pub Vec<String>);
126
127impl<'de> Deserialize<'de> for Hooks {
128 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
129 #[derive(Deserialize)]
130 #[serde(untagged)]
131 enum OneOrMany {
132 One(String),
133 Many(Vec<String>),
134 }
135 Ok(match OneOrMany::deserialize(d)? {
136 OneOrMany::One(s) => Hooks(vec![s]),
137 OneOrMany::Many(v) => Hooks(v),
138 })
139 }
140}
141
142pub fn parse(text: &str) -> Result<CloneConf, String> {
144 toml::from_str(text).map_err(|e| e.message().to_string())
145}
146
147pub fn expand_path(raw: &str, get: impl Fn(&str) -> Option<String>) -> String {
150 let mut s = raw.to_string();
151 if s == "~" {
152 return get("HOME").unwrap_or_default();
153 }
154 if let Some(rest) = s.strip_prefix("~/") {
155 s = format!("{}/{}", get("HOME").unwrap_or_default(), rest);
156 }
157 expand_vars(&s, get)
158}
159
160fn expand_vars(s: &str, get: impl Fn(&str) -> Option<String>) -> String {
161 let bytes = s.as_bytes();
162 let mut out = String::with_capacity(s.len());
163 let mut i = 0;
164 while i < bytes.len() {
165 if bytes[i] == b'$' {
166 let (name, next) = if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
167 match s[i + 2..].find('}').map(|e| i + 2 + e) {
168 Some(e) => (&s[i + 2..e], e + 1),
169 None => (&s[i + 1..i + 1], i + 1),
170 }
171 } else {
172 let mut j = i + 1;
173 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
174 j += 1;
175 }
176 (&s[i + 1..j], j)
177 };
178 if name.is_empty() {
179 out.push('$');
180 i += 1;
181 } else {
182 out.push_str(&get(name).unwrap_or_default());
183 i = next;
184 }
185 } else {
186 out.push(bytes[i] as char);
187 i += 1;
188 }
189 }
190 out
191}
192
193pub fn scp_url_parts(url: &str) -> Option<(String, String)> {
197 let url = url.trim();
198 if url.contains("://") || url.contains('@') {
199 return None;
200 }
201 let (host, path) = url.split_once(':')?;
202 let (namespace, _repo) = path.rsplit_once('/')?;
203 if host.is_empty() || namespace.is_empty() {
204 return None;
205 }
206 Some((host.to_string(), namespace.to_string()))
207}
208
209pub fn template(host: Option<&str>, namespace: Option<&str>) -> String {
212 let host = host.unwrap_or("<ssh-host-alias>");
213 let namespace = namespace.unwrap_or("<namespace>");
214 format!(
215 r#"# gkit clone config — run `gkit clone <this-file>`.
216host = "{host}" # ssh Host alias (~/.ssh/config); URL = host:namespace/repo.git
217namespace = "{namespace}" # GitHub org / GitLab group / user (optional — a repo may set its own)
218
219# `gkit.baseBranch` = this repo's integration branch. `gkit logoff` and `gkit stmb`
220# read it as the "base": the branch stmb returns to, and the one logoff checks
221# against. Stamped on every cloned repo here:
222post-clone = ["git config gkit.baseBranch main"] # change to your convention: master / dev
223
224# More optional global settings (uncomment as needed):
225# git-flags = ["-c", "http.lowSpeedLimit=1000"] # raw flags BEFORE `clone`
226# clone-flags = ["--filter=blob:none"] # raw flags AFTER `clone`
227# pre-clone = "echo cloning $GKIT_REPO"
228
229# One [[repo]] block per repo (name = basename of dir; $VAR/~ expanded):
230[[repo]]
231dir = "$HOME/work/example"
232# namespace = "other-org" # override the global namespace for THIS repo
233# name = "example" # remote repo name if it differs from the dir basename
234# depth = 1
235# branch = "dev"
236# clone-flags = ["--no-tags"]
237# post-clone = ["mill compile"]
238"#
239 )
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use std::collections::HashMap;
246
247 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
248 let m: HashMap<String, String> = pairs
249 .iter()
250 .map(|(k, v)| (k.to_string(), v.to_string()))
251 .collect();
252 move |k| m.get(k).cloned()
253 }
254
255 #[test]
256 fn parses_minimal_toml() {
257 let c = parse(
258 "host = \"tlbb\"\nnamespace = \"example-org\"\n[[repo]]\ndir = \"$CP_HOME/cp-conf\"\n",
259 )
260 .unwrap();
261 assert_eq!(c.host, "tlbb");
262 assert_eq!(c.namespace.as_deref(), Some("example-org"));
263 assert_eq!(c.repo.len(), 1);
264 assert_eq!(c.repo[0].name(), "cp-conf");
265 assert!(c.git_flags.is_empty() && c.pre_clone.0.is_empty());
266 }
267
268 #[test]
269 fn parses_full_toml_with_hooks_and_flags() {
270 let c = parse(
271 r#"
272host = "tlbb"
273namespace = "example-org"
274git-flags = ["-c", "http.x=y"]
275clone-flags = ["--filter=blob:none"]
276pre-clone = "echo global pre"
277post-clone = ["direnv allow ."]
278
279[[repo]]
280dir = "$D/cosp"
281depth = 1
282branch = "dev"
283clone-flags = ["--no-tags"]
284post-clone = ["mill compile", "echo done"]
285"#,
286 )
287 .unwrap();
288 assert_eq!(c.git_flags, ["-c", "http.x=y"]); assert_eq!(c.clone_flags, ["--filter=blob:none"]); assert_eq!(c.pre_clone.0, ["echo global pre"]); assert_eq!(c.post_clone.0, ["direnv allow ."]);
292 let r = &c.repo[0];
293 assert_eq!(r.depth, Some(1));
294 assert_eq!(r.branch.as_deref(), Some("dev"));
295 assert_eq!(r.clone_flags, ["--no-tags"]);
296 assert_eq!(r.post_clone.0, ["mill compile", "echo done"]); }
298
299 #[test]
300 fn name_overrides_basename_for_url() {
301 let c = parse(
303 "host=\"h\"\nnamespace=\"o\"\n[[repo]]\ndir=\"$HOME/work/my-cosp\"\nname=\"cosp\"\n",
304 )
305 .unwrap();
306 assert_eq!(c.repo[0].name(), "cosp"); let d =
309 parse("host=\"h\"\nnamespace=\"o\"\n[[repo]]\ndir=\"$HOME/work/my-cosp\"\n").unwrap();
310 assert_eq!(d.repo[0].name(), "my-cosp");
311 }
312
313 #[test]
314 fn requires_host() {
315 assert!(parse("namespace = \"o\"\n").unwrap_err().contains("host"));
317 }
318
319 #[test]
320 fn namespace_optional_at_parse() {
321 let c = parse("host = \"h\"\n").unwrap();
324 assert_eq!(c.namespace, None);
325 assert!(c.validate().is_ok()); }
327
328 #[test]
329 fn per_repo_namespace_overrides_global() {
330 let c = parse(
331 "host=\"gh\"\nnamespace=\"glob\"\n[[repo]]\ndir=\"$H/a\"\n[[repo]]\ndir=\"$H/b\"\nnamespace=\"bob\"\n",
332 )
333 .unwrap();
334 assert_eq!(c.namespace_for(&c.repo[0]), Some("glob")); assert_eq!(c.namespace_for(&c.repo[1]), Some("bob")); }
337
338 #[test]
339 fn validate_ok_with_per_repo_namespace_no_global() {
340 let c = parse(
342 "host=\"gh\"\n[[repo]]\ndir=\"$H/a\"\nnamespace=\"alice\"\n[[repo]]\ndir=\"$H/b\"\nnamespace=\"bob\"\n",
343 )
344 .unwrap();
345 assert!(c.validate().is_ok());
346 assert_eq!(c.namespace_for(&c.repo[0]), Some("alice"));
347 }
348
349 #[test]
350 fn validate_errors_when_no_namespace() {
351 let c = parse("host=\"gh\"\n[[repo]]\ndir=\"$H/lonely\"\n").unwrap();
353 let err = c.validate().unwrap_err();
354 assert!(err.contains("$H/lonely"), "names the dir: {err}");
355 assert!(err.contains("namespace"));
356 }
357
358 #[test]
359 fn rejects_unknown_field() {
360 assert!(parse("host=\"h\"\nnamespace=\"o\"\nbogus=1\n").is_err());
361 }
362
363 #[test]
364 fn scp_url_parses_alias_form_only() {
365 assert_eq!(
366 scp_url_parts("tlbb:example-org/cosp.git"),
367 Some(("tlbb".into(), "example-org".into()))
368 );
369 assert_eq!(
370 scp_url_parts("ctl:grp/sub/repo.git"),
371 Some(("ctl".into(), "grp/sub".into()))
372 ); assert_eq!(scp_url_parts("git@github.com:org/repo.git"), None); assert_eq!(scp_url_parts("https://github.com/org/repo.git"), None); assert_eq!(scp_url_parts("tlbb:noslash"), None);
376 }
377
378 #[test]
379 fn rejects_solo_field() {
380 assert!(parse("host=\"h\"\nnamespace=\"o\"\nsolo=true\n").is_err());
383 }
384
385 #[test]
386 fn template_fills_or_placeholders() {
387 let filled = template(Some("tlbb"), Some("example-org"));
388 assert!(filled.contains("host = \"tlbb\""));
389 assert!(filled.contains("namespace = \"example-org\""));
390 assert!(filled.contains("[[repo]]"));
391 assert!(filled.contains(r#"post-clone = ["git config gkit.baseBranch main"]"#));
392 let blank = template(None, None);
393 assert!(blank.contains("<ssh-host-alias>") && blank.contains("<namespace>"));
394 assert!(parse(&filled).is_ok());
396 }
397
398 #[test]
399 fn expands_home_and_vars() {
400 let get = env(&[("HOME", "/h"), ("CP_HOME", "/c"), ("X", "/x")]);
401 assert_eq!(expand_path("~/foo", &get), "/h/foo");
402 assert_eq!(expand_path("$CP_HOME/cp-conf", &get), "/c/cp-conf");
403 assert_eq!(expand_path("${X}/b", &get), "/x/b");
404 assert_eq!(expand_path("/abs", &get), "/abs");
405 assert_eq!(expand_path("$UNSET/y", &get), "/y");
406 }
407}