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)]
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 pub dir: String,
67 #[serde(default)]
70 pub namespace: Option<String>,
71 #[serde(default)]
74 pub name: Option<String>,
75 #[serde(default)]
76 pub depth: Option<u32>,
77 #[serde(default)]
78 pub branch: Option<String>,
79 #[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 #[serde(default)]
88 pub solo: Option<bool>,
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 solo_for(&self, repo: &Repo) -> Option<bool> {
101 repo.solo.or(self.solo)
102 }
103
104 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 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#[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
158pub fn parse(text: &str) -> Result<CloneConf, String> {
160 toml::from_str(text).map_err(|e| e.message().to_string())
161}
162
163pub 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
209pub 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
225pub 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"]); 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 ."]);
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"]); }
320
321 #[test]
322 fn name_overrides_basename_for_url() {
323 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"); 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 assert!(parse("namespace = \"o\"\n").unwrap_err().contains("host"));
339 }
340
341 #[test]
342 fn namespace_optional_at_parse() {
343 let c = parse("host = \"h\"\n").unwrap();
346 assert_eq!(c.namespace, None);
347 assert!(c.validate().is_ok()); }
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")); assert_eq!(c.namespace_for(&c.repo[1]), Some("bob")); }
359
360 #[test]
361 fn validate_ok_with_per_repo_namespace_no_global() {
362 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 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 ); 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);
398 }
399
400 #[test]
401 fn solo_global_and_per_repo_override() {
402 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)); assert_eq!(c.solo_for(&c.repo[1]), Some(false)); 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 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 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}