1use 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 #[serde(default)]
40 pub namespace: Option<String>,
41 #[serde(default)]
43 pub git_flags: Vec<String>,
44 #[serde(default)]
46 pub clone_flags: Vec<String>,
47 #[serde(default)]
49 pub pre_clone: Hooks,
50 #[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 pub dir: String,
62 #[serde(default)]
65 pub namespace: Option<String>,
66 #[serde(default)]
69 pub name: Option<String>,
70 #[serde(default)]
71 pub depth: Option<u32>,
72 #[serde(default)]
76 pub branch: Option<String>,
77 #[serde(default)]
81 pub single_branch: bool,
82 #[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 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 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 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#[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
151pub fn parse(text: &str) -> Result<CloneConf, String> {
153 toml::from_str(text).map_err(|e| e.message().to_string())
154}
155
156pub 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
202pub 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
218pub 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"]); 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 ."]);
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); assert_eq!(r.clone_flags, ["--no-tags"]);
307 assert_eq!(r.post_clone.0, ["mill compile", "echo done"]); }
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 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"); 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 assert!(parse("namespace = \"o\"\n").unwrap_err().contains("host"));
338 }
339
340 #[test]
341 fn namespace_optional_at_parse() {
342 let c = parse("host = \"h\"\n").unwrap();
345 assert_eq!(c.namespace, None);
346 assert!(c.validate().is_ok()); }
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")); assert_eq!(c.namespace_for(&c.repo[1]), Some("bob")); }
358
359 #[test]
360 fn validate_ok_with_per_repo_namespace_no_global() {
361 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 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 ); 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);
397 }
398
399 #[test]
400 fn rejects_solo_field() {
401 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 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}