Skip to main content

githops_core/
config.rs

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
10/// The JSON Schema for `githops.yaml`, embedded at compile time.
11/// Use [`write_schema`] to materialize it on disk.
12pub const SCHEMA_JSON: &str = include_str!("../githops.schema.json");
13
14/// Write the embedded JSON Schema to `<dir>/.githops/githops.schema.json` if it is
15/// absent or differs from the embedded version.
16pub 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    /// Schema version
34    #[serde(default = "default_version")]
35    pub version: String,
36
37    /// Reusable command definitions for YAML anchors.
38    ///
39    /// Define command templates here using YAML anchors (&name), then
40    /// reference them with YAML aliases (*name) inside hook command lists.
41    /// A list alias is automatically flattened into the parent sequence.
42    ///
43    /// Single command anchor:
44    /// ```yaml
45    /// definitions:
46    ///   lint: &lint
47    ///     name: lint
48    ///     run: cargo clippy -- -D warnings
49    /// ```
50    ///
51    /// List-of-commands anchor:
52    /// ```yaml
53    /// definitions:
54    ///   quality: &quality
55    ///     - name: lint
56    ///       run: cargo clippy
57    ///     - name: audit
58    ///       run: cargo audit
59    /// ```
60    ///
61    /// Usage in hooks (list aliases are inlined automatically):
62    /// ```yaml
63    /// hooks:
64    ///   pre-commit:
65    ///     commands:
66    ///       - name: fmt
67    ///         run: cargo fmt --check
68    ///       - *lint        # single command
69    ///       - *quality     # expands to two commands inline
70    /// ```
71    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72    pub definitions: BTreeMap<String, DefinitionEntry>,
73
74    /// Hook definitions. Keys are git hook names (e.g. pre-commit, commit-msg).
75    #[serde(default)]
76    pub hooks: Hooks,
77
78    /// Smart caching — skip commands whose inputs haven't changed.
79    ///
80    /// Set `enabled: true` here, then add a `cache.inputs` list to each
81    /// command you want to cache.  The cache key is a SHA-256 of the command
82    /// script, any extra `cache.key` strings, and the content of every input
83    /// file.  A cache hit causes the command to be skipped with a "cached"
84    /// message.  Cache entries are stored in `.githops/cache/` (or `cache.dir`).
85    ///
86    /// Example:
87    /// ```yaml
88    /// cache:
89    ///   enabled: true
90    ///
91    /// hooks:
92    ///   pre-commit:
93    ///     commands:
94    ///       - name: lint
95    ///         run: cargo clippy -- -D warnings
96    ///         cache:
97    ///           inputs: ["src/**/*.rs", "Cargo.toml"]
98    ///       - name: test
99    ///         run: cargo test
100    ///         cache:
101    ///           inputs: ["src/**/*.rs", "tests/**/*.rs"]
102    ///           key: ["$RUST_TOOLCHAIN"]
103    /// ```
104    #[serde(default, skip_serializing_if = "GlobalCache::is_unconfigured")]
105    pub cache: GlobalCache,
106}
107
108/// Global cache settings.
109#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
110#[serde(rename_all = "kebab-case")]
111pub struct GlobalCache {
112    /// Enable caching.  Commands without a `cache` block are always executed.
113    #[serde(default)]
114    pub enabled: bool,
115
116    /// Override the cache directory (default: `.githops/cache`).
117    #[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/// Per-command cache configuration.
134#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
135#[serde(rename_all = "kebab-case")]
136pub struct CommandCache {
137    /// File glob patterns treated as inputs.
138    /// The command is re-run only when the content of any matching file changes.
139    /// Globs are relative to the repository root.  Example: `["src/**/*.rs", "Cargo.toml"]`
140    #[serde(default)]
141    pub inputs: Vec<String>,
142
143    /// Extra strings mixed into the cache key (e.g. environment variable values
144    /// or tool version strings).  Example: `["$RUST_TOOLCHAIN"]`
145    #[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/// A reusable command definition: a single command mapping or a list of commands.
154#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
155#[serde(untagged)]
156pub enum DefinitionEntry {
157    /// A list of commands that will be inlined when the anchor is used.
158    List(Vec<Command>),
159    /// A single command.
160    Single(Command),
161}
162
163/// All supported git hooks. Configure any hook by adding its name as a key.
164#[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    /// Get a hook config by its git hook name (e.g. "pre-commit").
254    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    /// Set a hook config by its git hook name.
289    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    /// Remove a hook from the config by its git hook name.
324    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    /// Whether this hook is active. Set to false to temporarily disable.
362    #[serde(default = "default_true")]
363    pub enabled: bool,
364
365    /// Run commands concurrently within each dependency wave.
366    ///
367    /// When `true`, commands that have no dependency relationship are started at
368    /// the same time in separate threads.  Commands that share a `depends` link
369    /// are still serialised — the dependent command waits until all its
370    /// dependencies finish successfully.
371    ///
372    /// Use this to speed up independent checks (e.g. lint + test) while keeping
373    /// ordered steps (e.g. build → deploy) sequential.
374    ///
375    /// Example:
376    /// ```yaml
377    /// hooks:
378    ///   pre-push:
379    ///     parallel: true
380    ///     commands:
381    ///       - name: lint
382    ///         run: cargo clippy
383    ///       - name: test
384    ///         run: cargo test   # runs at the same time as lint
385    /// ```
386    #[serde(default)]
387    pub parallel: bool,
388
389    /// Commands to run when this hook fires.
390    /// Each entry is either an inline command or a `$ref` to a definition.
391    #[serde(default)]
392    pub commands: Vec<CommandEntry>,
393}
394
395impl HookConfig {
396    /// Resolve all command entries to concrete Commands by expanding `$ref` entries
397    /// using the given definitions map. Unknown refs are silently skipped.
398    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/// A command entry in a hook's command list: either an inline command definition
434/// or a reference to a named definition (`$ref: name`).
435///
436/// The `$ref` form lets you reuse commands defined in the `definitions` section
437/// without YAML anchors, so changes round-trip correctly through the UI editor.
438///
439/// Example using `$ref`:
440/// ```yaml
441/// hooks:
442///   pre-commit:
443///     commands:
444///       - name: fmt
445///         run: cargo fmt --check
446///       - $ref: lint   # references definitions.lint
447/// ```
448#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
449#[serde(untagged)]
450pub enum CommandEntry {
451    /// A reference to a named definition. Serialises as `{$ref: name}`.
452    Ref(RefEntry),
453    /// An inline command definition.
454    Inline(Command),
455}
456
457impl From<Command> for CommandEntry {
458    fn from(cmd: Command) -> Self {
459        CommandEntry::Inline(cmd)
460    }
461}
462
463/// A reference to a named definition in the `definitions` section.
464///
465/// Supports two optional overrides that are applied at the point of use,
466/// without modifying the shared definition:
467///
468/// ```yaml
469/// hooks:
470///   pre-commit:
471///     commands:
472///       - $ref: lint                  # use definition as-is
473///       - $ref: lint
474///         args: "--fix"               # appends to the definition's run command
475///         name: lint-fix              # overrides the display label
476/// ```
477#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
478pub struct RefEntry {
479    /// Name of the definition to reference.
480    #[serde(rename = "$ref")]
481    pub r#ref: String,
482
483    /// Extra arguments appended to the definition's `run` command.
484    ///
485    /// The final command executed is `{definition.run} {args}`.
486    /// For example, if the definition runs `npm run lint`, setting
487    /// `args: "--fix"` produces `npm run lint --fix`.
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub args: Option<String>,
490
491    /// Override the display name shown in hook output for this specific use.
492    /// When omitted, the definition's own `name` is used.
493    #[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    /// Human-readable label shown in output
500    pub name: String,
501
502    /// Shell command to execute. Hook arguments are available as $1, $2, etc.
503    pub run: String,
504
505    /// Names of commands in this hook that must complete successfully before
506    /// this command starts. Forms a DAG; cycles are detected and rejected.
507    #[serde(default, skip_serializing_if = "Vec::is_empty")]
508    pub depends: Vec<String>,
509
510    /// Additional environment variables for this command
511    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
512    pub env: BTreeMap<String, String>,
513
514    /// Mark this command as a test-only command (informational; not run during normal hooks).
515    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
516    pub test: bool,
517
518    /// Cache configuration for this command.
519    /// Requires `cache.enabled: true` in the top-level config.
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub cache: Option<CommandCache>,
522}
523
524// ---------------------------------------------------------------------------
525// YAML pre-processor
526// ---------------------------------------------------------------------------
527
528fn 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    /// Find and load config from the current directory.
591    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
623// ---------------------------------------------------------------------------
624// Validation (shared with graphui)
625// ---------------------------------------------------------------------------
626
627pub 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}