1use anyhow::{Context, Result};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7pub const CONFIG_FILE: &str = "githops.yaml";
8pub const SCHEMA_FILE: &str = ".githops/githops.schema.json";
9
10pub const SCHEMA_JSON: &str = include_str!("../githops.schema.json");
13
14pub fn write_schema(dir: &std::path::Path) -> anyhow::Result<()> {
17 let githops_dir = dir.join(".githops");
18 std::fs::create_dir_all(&githops_dir)?;
19 let path = dir.join(SCHEMA_FILE);
20 let needs_write = match std::fs::read_to_string(&path) {
21 Ok(existing) => existing != SCHEMA_JSON,
22 Err(_) => true,
23 };
24 if needs_write {
25 std::fs::write(&path, SCHEMA_JSON)?;
26 }
27 Ok(())
28}
29
30#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
31#[serde(rename_all = "kebab-case")]
32pub struct Config {
33 #[serde(default = "default_version")]
35 pub version: String,
36
37 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72 pub definitions: BTreeMap<String, DefinitionEntry>,
73
74 #[serde(default)]
76 pub hooks: Hooks,
77
78 #[serde(default, skip_serializing_if = "GlobalCache::is_unconfigured")]
105 pub cache: GlobalCache,
106}
107
108#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
110#[serde(rename_all = "kebab-case")]
111pub struct GlobalCache {
112 #[serde(default)]
114 pub enabled: bool,
115
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub dir: Option<String>,
119}
120
121impl GlobalCache {
122 pub fn is_unconfigured(&self) -> bool {
123 !self.enabled && self.dir.is_none()
124 }
125
126 pub fn cache_dir(&self) -> std::path::PathBuf {
127 std::path::PathBuf::from(
128 self.dir.as_deref().unwrap_or(".githops/cache"),
129 )
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
135#[serde(rename_all = "kebab-case")]
136pub struct CommandCache {
137 #[serde(default)]
141 pub inputs: Vec<String>,
142
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub key: Vec<String>,
147}
148
149fn default_version() -> String {
150 "1".to_string()
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
155#[serde(untagged)]
156pub enum DefinitionEntry {
157 List(Vec<Command>),
159 Single(Command),
161}
162
163#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
165#[serde(rename_all = "kebab-case")]
166pub struct Hooks {
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub applypatch_msg: Option<HookConfig>,
169
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub pre_applypatch: Option<HookConfig>,
172
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub post_applypatch: Option<HookConfig>,
175
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub pre_commit: Option<HookConfig>,
178
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub pre_merge_commit: Option<HookConfig>,
181
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub prepare_commit_msg: Option<HookConfig>,
184
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub commit_msg: Option<HookConfig>,
187
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub post_commit: Option<HookConfig>,
190
191 #[serde(skip_serializing_if = "Option::is_none")]
192 pub pre_rebase: Option<HookConfig>,
193
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub post_checkout: Option<HookConfig>,
196
197 #[serde(skip_serializing_if = "Option::is_none")]
198 pub post_merge: Option<HookConfig>,
199
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub pre_push: Option<HookConfig>,
202
203 #[serde(skip_serializing_if = "Option::is_none")]
204 pub pre_receive: Option<HookConfig>,
205
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub update: Option<HookConfig>,
208
209 #[serde(skip_serializing_if = "Option::is_none")]
210 pub proc_receive: Option<HookConfig>,
211
212 #[serde(skip_serializing_if = "Option::is_none")]
213 pub post_receive: Option<HookConfig>,
214
215 #[serde(skip_serializing_if = "Option::is_none")]
216 pub post_update: Option<HookConfig>,
217
218 #[serde(skip_serializing_if = "Option::is_none")]
219 pub reference_transaction: Option<HookConfig>,
220
221 #[serde(skip_serializing_if = "Option::is_none")]
222 pub push_to_checkout: Option<HookConfig>,
223
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub pre_auto_gc: Option<HookConfig>,
226
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub post_rewrite: Option<HookConfig>,
229
230 #[serde(skip_serializing_if = "Option::is_none")]
231 pub sendemail_validate: Option<HookConfig>,
232
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub fsmonitor_watchman: Option<HookConfig>,
235
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub p4_changelist: Option<HookConfig>,
238
239 #[serde(skip_serializing_if = "Option::is_none")]
240 pub p4_prepare_changelist: Option<HookConfig>,
241
242 #[serde(skip_serializing_if = "Option::is_none")]
243 pub p4_post_changelist: Option<HookConfig>,
244
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub p4_pre_submit: Option<HookConfig>,
247
248 #[serde(skip_serializing_if = "Option::is_none")]
249 pub post_index_change: Option<HookConfig>,
250}
251
252impl Hooks {
253 pub fn get(&self, name: &str) -> Option<&HookConfig> {
255 match name {
256 "applypatch-msg" => self.applypatch_msg.as_ref(),
257 "pre-applypatch" => self.pre_applypatch.as_ref(),
258 "post-applypatch" => self.post_applypatch.as_ref(),
259 "pre-commit" => self.pre_commit.as_ref(),
260 "pre-merge-commit" => self.pre_merge_commit.as_ref(),
261 "prepare-commit-msg" => self.prepare_commit_msg.as_ref(),
262 "commit-msg" => self.commit_msg.as_ref(),
263 "post-commit" => self.post_commit.as_ref(),
264 "pre-rebase" => self.pre_rebase.as_ref(),
265 "post-checkout" => self.post_checkout.as_ref(),
266 "post-merge" => self.post_merge.as_ref(),
267 "pre-push" => self.pre_push.as_ref(),
268 "pre-receive" => self.pre_receive.as_ref(),
269 "update" => self.update.as_ref(),
270 "proc-receive" => self.proc_receive.as_ref(),
271 "post-receive" => self.post_receive.as_ref(),
272 "post-update" => self.post_update.as_ref(),
273 "reference-transaction" => self.reference_transaction.as_ref(),
274 "push-to-checkout" => self.push_to_checkout.as_ref(),
275 "pre-auto-gc" => self.pre_auto_gc.as_ref(),
276 "post-rewrite" => self.post_rewrite.as_ref(),
277 "sendemail-validate" => self.sendemail_validate.as_ref(),
278 "fsmonitor-watchman" => self.fsmonitor_watchman.as_ref(),
279 "p4-changelist" => self.p4_changelist.as_ref(),
280 "p4-prepare-changelist" => self.p4_prepare_changelist.as_ref(),
281 "p4-post-changelist" => self.p4_post_changelist.as_ref(),
282 "p4-pre-submit" => self.p4_pre_submit.as_ref(),
283 "post-index-change" => self.post_index_change.as_ref(),
284 _ => None,
285 }
286 }
287
288 pub fn set(&mut self, name: &str, cfg: HookConfig) {
290 match name {
291 "applypatch-msg" => self.applypatch_msg = Some(cfg),
292 "pre-applypatch" => self.pre_applypatch = Some(cfg),
293 "post-applypatch" => self.post_applypatch = Some(cfg),
294 "pre-commit" => self.pre_commit = Some(cfg),
295 "pre-merge-commit" => self.pre_merge_commit = Some(cfg),
296 "prepare-commit-msg" => self.prepare_commit_msg = Some(cfg),
297 "commit-msg" => self.commit_msg = Some(cfg),
298 "post-commit" => self.post_commit = Some(cfg),
299 "pre-rebase" => self.pre_rebase = Some(cfg),
300 "post-checkout" => self.post_checkout = Some(cfg),
301 "post-merge" => self.post_merge = Some(cfg),
302 "pre-push" => self.pre_push = Some(cfg),
303 "pre-receive" => self.pre_receive = Some(cfg),
304 "update" => self.update = Some(cfg),
305 "proc-receive" => self.proc_receive = Some(cfg),
306 "post-receive" => self.post_receive = Some(cfg),
307 "post-update" => self.post_update = Some(cfg),
308 "reference-transaction" => self.reference_transaction = Some(cfg),
309 "push-to-checkout" => self.push_to_checkout = Some(cfg),
310 "pre-auto-gc" => self.pre_auto_gc = Some(cfg),
311 "post-rewrite" => self.post_rewrite = Some(cfg),
312 "sendemail-validate" => self.sendemail_validate = Some(cfg),
313 "fsmonitor-watchman" => self.fsmonitor_watchman = Some(cfg),
314 "p4-changelist" => self.p4_changelist = Some(cfg),
315 "p4-prepare-changelist" => self.p4_prepare_changelist = Some(cfg),
316 "p4-post-changelist" => self.p4_post_changelist = Some(cfg),
317 "p4-pre-submit" => self.p4_pre_submit = Some(cfg),
318 "post-index-change" => self.post_index_change = Some(cfg),
319 _ => {}
320 }
321 }
322
323 pub fn remove(&mut self, name: &str) {
325 match name {
326 "applypatch-msg" => self.applypatch_msg = None,
327 "pre-applypatch" => self.pre_applypatch = None,
328 "post-applypatch" => self.post_applypatch = None,
329 "pre-commit" => self.pre_commit = None,
330 "pre-merge-commit" => self.pre_merge_commit = None,
331 "prepare-commit-msg" => self.prepare_commit_msg = None,
332 "commit-msg" => self.commit_msg = None,
333 "post-commit" => self.post_commit = None,
334 "pre-rebase" => self.pre_rebase = None,
335 "post-checkout" => self.post_checkout = None,
336 "post-merge" => self.post_merge = None,
337 "pre-push" => self.pre_push = None,
338 "pre-receive" => self.pre_receive = None,
339 "update" => self.update = None,
340 "proc-receive" => self.proc_receive = None,
341 "post-receive" => self.post_receive = None,
342 "post-update" => self.post_update = None,
343 "reference-transaction" => self.reference_transaction = None,
344 "push-to-checkout" => self.push_to_checkout = None,
345 "pre-auto-gc" => self.pre_auto_gc = None,
346 "post-rewrite" => self.post_rewrite = None,
347 "sendemail-validate" => self.sendemail_validate = None,
348 "fsmonitor-watchman" => self.fsmonitor_watchman = None,
349 "p4-changelist" => self.p4_changelist = None,
350 "p4-prepare-changelist" => self.p4_prepare_changelist = None,
351 "p4-post-changelist" => self.p4_post_changelist = None,
352 "p4-pre-submit" => self.p4_pre_submit = None,
353 "post-index-change" => self.post_index_change = None,
354 _ => {}
355 }
356 }
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
360pub struct HookConfig {
361 #[serde(default = "default_true")]
363 pub enabled: bool,
364
365 #[serde(default)]
387 pub parallel: bool,
388
389 #[serde(default)]
392 pub commands: Vec<CommandEntry>,
393}
394
395impl HookConfig {
396 pub fn resolved_commands<'a>(
399 &'a self,
400 definitions: &'a BTreeMap<String, DefinitionEntry>,
401 ) -> Vec<Command> {
402 let mut out = Vec::new();
403 for entry in &self.commands {
404 match entry {
405 CommandEntry::Inline(cmd) => out.push(cmd.clone()),
406 CommandEntry::Ref(r) => {
407 if let Some(def) = definitions.get(&r.r#ref) {
408 match def {
409 DefinitionEntry::Single(cmd) => {
410 let mut cmd = cmd.clone();
411 if let Some(args) = &r.args {
412 cmd.run = format!("{} {}", cmd.run, args);
413 }
414 if let Some(name) = &r.name {
415 cmd.name = name.clone();
416 }
417 out.push(cmd);
418 }
419 DefinitionEntry::List(cmds) => out.extend(cmds.iter().cloned()),
420 }
421 }
422 }
423 }
424 }
425 out
426 }
427}
428
429fn default_true() -> bool {
430 true
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
449#[serde(untagged)]
450pub enum CommandEntry {
451 Ref(RefEntry),
453 Inline(Command),
455}
456
457impl From<Command> for CommandEntry {
458 fn from(cmd: Command) -> Self {
459 CommandEntry::Inline(cmd)
460 }
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
478pub struct RefEntry {
479 #[serde(rename = "$ref")]
481 pub r#ref: String,
482
483 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub args: Option<String>,
490
491 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub name: Option<String>,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
498pub struct Command {
499 pub name: String,
501
502 pub run: String,
504
505 #[serde(default, skip_serializing_if = "Vec::is_empty")]
508 pub depends: Vec<String>,
509
510 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
512 pub env: BTreeMap<String, String>,
513
514 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
516 pub test: bool,
517
518 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub cache: Option<CommandCache>,
522}
523
524fn flatten_command_aliases(root: &mut serde_yaml::Value) {
529 let root_map = match root.as_mapping_mut() {
530 Some(m) => m,
531 None => return,
532 };
533
534 let hooks_key = serde_yaml::Value::String("hooks".into());
535 let hooks = match root_map.get_mut(&hooks_key) {
536 Some(h) => h,
537 None => return,
538 };
539 let hooks_map = match hooks.as_mapping_mut() {
540 Some(m) => m,
541 None => return,
542 };
543
544 let hook_keys: Vec<serde_yaml::Value> = hooks_map.keys().cloned().collect();
545
546 for hk in hook_keys {
547 let hook_val = match hooks_map.get_mut(&hk) {
548 Some(v) => v,
549 None => continue,
550 };
551 let hook_map = match hook_val.as_mapping_mut() {
552 Some(m) => m,
553 None => continue,
554 };
555
556 let cmds_key = serde_yaml::Value::String("commands".into());
557 let cmds_val = match hook_map.get_mut(&cmds_key) {
558 Some(v) => v,
559 None => continue,
560 };
561 let seq = match cmds_val.as_sequence_mut() {
562 Some(s) => s,
563 None => continue,
564 };
565
566 let original: Vec<serde_yaml::Value> = seq.drain(..).collect();
567 for item in original {
568 match item {
569 serde_yaml::Value::Sequence(inner) => seq.extend(inner),
570 other => seq.push(other),
571 }
572 }
573 }
574}
575
576impl Config {
577 pub fn load(path: &Path) -> Result<Self> {
578 let content = std::fs::read_to_string(path)
579 .with_context(|| format!("Failed to read {}", path.display()))?;
580
581 let mut raw: serde_yaml::Value = serde_yaml::from_str(&content)
582 .with_context(|| format!("Failed to parse YAML in {}", path.display()))?;
583
584 flatten_command_aliases(&mut raw);
585
586 serde_yaml::from_value(raw)
587 .with_context(|| format!("Failed to deserialise {}", path.display()))
588 }
589
590 pub fn find() -> Result<(Self, PathBuf)> {
592 let path = Path::new(CONFIG_FILE);
593 if path.exists() {
594 return Ok((Self::load(path)?, path.to_path_buf()));
595 }
596 anyhow::bail!(
597 "No {} found in the current directory. Run `githops init` first.",
598 CONFIG_FILE
599 )
600 }
601
602 pub fn save(&self, path: &Path) -> Result<()> {
603 let yaml_body = serde_yaml::to_string(self)?;
604 let content = if path.exists() {
605 let existing = std::fs::read_to_string(path).unwrap_or_default();
606 let first = existing.lines().next().unwrap_or("");
607 if first.starts_with("# yaml-language-server:") {
608 format!("{}\n{}", first, yaml_body)
609 } else {
610 yaml_body
611 }
612 } else {
613 format!(
614 "# yaml-language-server: $schema={}\n{}",
615 SCHEMA_FILE, yaml_body
616 )
617 };
618 std::fs::write(path, content)?;
619 Ok(())
620 }
621}
622
623pub fn validate_depends_pub(commands: &[Command]) -> Result<()> {
628 let names: std::collections::HashSet<&str> =
629 commands.iter().map(|c| c.name.as_str()).collect();
630
631 for cmd in commands {
632 for dep in &cmd.depends {
633 if !names.contains(dep.as_str()) {
634 anyhow::bail!(
635 "Command '{}' depends on '{}', which is not defined in this hook.",
636 cmd.name,
637 dep
638 );
639 }
640 if dep == &cmd.name {
641 anyhow::bail!("Command '{}' depends on itself.", cmd.name);
642 }
643 }
644 }
645 Ok(())
646}