Repository: j178/prefligit (language: python)

Issue:
Implement meta hooks



Discussion / hints:

Fix patch:
diff --git a/src/config.rs b/src/config.rs
index 4762714..bc7cfc9 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -5,6 +5,7 @@ use std::path::Path;
 use std::str::FromStr;
 
 use anyhow::Result;
+use fancy_regex as regex;
 use serde::{Deserialize, Deserializer, Serialize};
 use url::Url;
 
@@ -275,33 +276,26 @@ impl Display for RepoLocation {
     }
 }
 
-/// A remote hook in the configuration file.
-///
-/// All keys in manifest hook dict are valid in a config hook dict, but are optional.
-#[derive(Debug, Clone, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub struct ConfigRemoteHook {
-    /// The id of the hook.
-    pub id: String,
-    /// Override the name of the hook.
-    pub name: Option<String>,
+/// Common hook options.
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct HookOptions {
     /// Not documented in the official docs.
-    pub entry: Option<String>,
-    /// Not documented in the official docs.
-    pub language: Option<Language>,
-    /// Allows the hook to be referenced using an additional id when using pre-commit run <hookid>
     pub alias: Option<String>,
-    /// Override the pattern of files to run on.
+    /// The pattern of files to run on.
     pub files: Option<String>,
-    /// Override the pattern of files to exclude.
+    /// Exclude files that were matched by `files`.
+    /// Default is `$^`, which matches nothing.
     pub exclude: Option<String>,
-    /// Override the types of files to run on (AND).
+    /// List of file types to run on (AND).
+    /// Default is `[file]`, which matches all files.
     pub types: Option<Vec<String>>,
-    /// Override the types of files to run on (OR).
+    /// List of file types to run on (OR).
+    /// Default is `[]`.
     pub types_or: Option<Vec<String>>,
-    /// Override the types of files to exclude.
+    /// List of file types to exclude.
+    /// Default is `[]`.
     pub exclude_types: Option<Vec<String>>,
-    /// Additional dependencies to install in the environment where the hook runs.
+    /// Not documented in the official docs.
     pub additional_dependencies: Option<Vec<String>>,
     /// Additional arguments to pass to the hook.
     pub args: Option<Vec<String>>,
@@ -335,6 +329,59 @@ pub struct ConfigRemoteHook {
     pub minimum_pre_commit_version: Option<String>,
 }
 
+impl HookOptions {
+    pub fn update(&mut self, other: &Self) {
+        macro_rules! update_if_some {
+            ($($field:ident),* $(,)?) => {
+                $(
+                if other.$field.is_some() {
+                    self.$field.clone_from(&other.$field);
+                }
+                )*
+            };
+        }
+
+        update_if_some!(
+            alias,
+            files,
+            exclude,
+            types,
+            types_or,
+            exclude_types,
+            additional_dependencies,
+            args,
+            always_run,
+            fail_fast,
+            pass_filenames,
+            description,
+            language_version,
+            log_file,
+            require_serial,
+            stages,
+            verbose,
+            minimum_pre_commit_version,
+        );
+    }
+}
+
+/// A remote hook in the configuration file.
+///
+/// All keys in manifest hook dict are valid in a config hook dict, but are optional.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct ConfigRemoteHook {
+    /// The id of the hook.
+    pub id: String,
+    /// Override the name of the hook.
+    pub name: Option<String>,
+    /// Not documented in the official docs.
+    pub entry: Option<String>,
+    /// Not documented in the official docs.
+    pub language: Option<Language>,
+    #[serde(flatten)]
+    pub options: HookOptions,
+}
+
 /// A local hook in the configuration file.
 ///
 /// It's the same as the manifest hook definition.
@@ -348,30 +395,99 @@ pub enum MetaHookID {
     Identify,
 }
 
-impl MetaHookID {
-    pub fn as_str(&self) -> &str {
-        match self {
+impl Display for MetaHookID {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let name = match self {
             MetaHookID::CheckHooksApply => "check-hooks-apply",
             MetaHookID::CheckUselessExcludes => "check-useless-excludes",
             MetaHookID::Identify => "identify",
+        };
+        f.write_str(name)
+    }
+}
+
+impl FromStr for MetaHookID {
+    type Err = ();
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "check-hooks-apply" => Ok(MetaHookID::CheckHooksApply),
+            "check-useless-excludes" => Ok(MetaHookID::CheckUselessExcludes),
+            "identify" => Ok(MetaHookID::Identify),
+            _ => Err(()),
         }
     }
 }
 
-impl Display for MetaHookID {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.write_str(self.as_str())
+/// A meta hook predefined in pre-commit.
+///
+/// It's the same as the manifest hook definition but with only a few predefined id allowed.
+#[derive(Debug, Clone)]
+pub struct ConfigMetaHook(ManifestHook);
+
+impl<'de> Deserialize<'de> for ConfigMetaHook {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let hook = ConfigRemoteHook::deserialize(deserializer)?;
+
+        let id = MetaHookID::from_str(&hook.id)
+            .map_err(|()| serde::de::Error::custom("Unknown meta hook id"))?;
+        if hook.language.is_some_and(|l| l != Language::System) {
+            return Err(serde::de::Error::custom(
+                "language must be system for meta hook",
+            ));
+        }
+        if hook.entry.is_some() {
+            return Err(serde::de::Error::custom(
+                "entry is not allowed for meta hook",
+            ));
+        }
+
+        let mut defaults = match id {
+            MetaHookID::CheckHooksApply => ManifestHook {
+                id: "check-hooks-apply".to_string(),
+                name: "Check hooks apply".to_string(),
+                language: Language::System,
+                entry: "a".to_string(), // TODO: direct call to the hook
+                options: HookOptions {
+                    files: Some(format!("^{}$", regex::escape(CONFIG_FILE))),
+                    ..Default::default()
+                },
+            },
+            MetaHookID::CheckUselessExcludes => ManifestHook {
+                id: "check-useless-excludes".to_string(),
+                name: "Check useless excludes".to_string(),
+                language: Language::System,
+                entry: "a".to_string(),
+                options: HookOptions {
+                    files: Some(format!("^{}$", regex::escape(CONFIG_FILE))),
+                    ..Default::default()
+                },
+            },
+            MetaHookID::Identify => ManifestHook {
+                id: "identify".to_string(),
+                name: "identify".to_string(),
+                language: Language::System,
+                entry: "a".to_string(),
+                options: HookOptions {
+                    verbose: Some(true),
+                    ..Default::default()
+                },
+            },
+        };
+
+        defaults.options.update(&hook.options);
+
+        Ok(ConfigMetaHook(defaults))
     }
 }
 
-#[derive(Debug, Clone, Deserialize)]
-#[serde(rename_all = "snake_case")]
-#[serde(deny_unknown_fields)]
-pub struct ConfigMetaHook {
-    pub id: MetaHookID,
-    // only "system" is allowed
-    pub language: Option<Language>,
-    // TODO: entry is not allowed
+impl From<ConfigMetaHook> for ManifestHook {
+    fn from(hook: ConfigMetaHook) -> Self {
+        hook.0
+    }
 }
 
 #[derive(Debug, Clone)]
@@ -506,54 +622,8 @@ pub struct ManifestHook {
     pub entry: String,
     /// The language of the hook. Tells pre-commit how to install and run the hook.
     pub language: Language,
-    /// Not documented in the official docs.
-    pub alias: Option<String>,
-    /// The pattern of files to run on.
-    pub files: Option<String>,
-    /// Exclude files that were matched by `files`.
-    /// Default is `$^`, which matches nothing.
-    pub exclude: Option<String>,
-    /// List of file types to run on (AND).
-    /// Default is `[file]`, which matches all files.
-    pub types: Option<Vec<String>>,
-    /// List of file types to run on (OR).
-    /// Default is `[]`.
-    pub types_or: Option<Vec<String>>,
-    /// List of file types to exclude.
-    /// Default is `[]`.
-    pub exclude_types: Option<Vec<String>>,
-    /// Not documented in the official docs.
-    pub additional_dependencies: Option<Vec<String>>,
-    /// Additional arguments to pass to the hook.
-    pub args: Option<Vec<String>>,
-    /// This hook will run even if there are no matching files.
-    /// Default is false.
-    pub always_run: Option<bool>,
-    /// If this hook fails, don't run any more hooks.
-    /// Default is false.
-    pub fail_fast: Option<bool>,
-    /// Append filenames that would be checked to the hook entry as arguments.
-    /// Default is true.
-    pub pass_filenames: Option<bool>,
-    /// A description of the hook. For metadata only.
-    pub description: Option<String>,
-    /// Run the hook on a specific version of the language.
-    /// Default is `default`.
-    /// See <https://pre-commit.com/#overriding-language-version>.
-    pub language_version: Option<String>,
-    /// Write the output of the hook to a file when the hook fails or verbose is enabled.
-    pub log_file: Option<String>,
-    /// This hook will execute using a single process instead of in parallel.
-    /// Default is false.
-    pub require_serial: Option<bool>,
-    /// Select which git hook(s) to run for.
-    /// Default all stages are selected.
-    /// See <https://pre-commit.com/#confining-hooks-to-run-at-certain-stages>.
-    pub stages: Option<Vec<Stage>>,
-    /// Print the output of the hook even if it passes.
-    /// Default is false.
-    pub verbose: Option<bool>,
-    pub minimum_pre_commit_version: Option<String>,
+    #[serde(flatten)]
+    pub options: HookOptions,
 }
 
 #[derive(Debug, Clone, Deserialize)]
@@ -631,24 +701,26 @@ mod tests {
                                     name: "cargo fmt",
                                     entry: "cargo fmt --",
                                     language: System,
-                                    alias: None,
-                                    files: None,
-                                    exclude: None,
-                                    types: None,
-                                    types_or: None,
-                                    exclude_types: None,
-                                    additional_dependencies: None,
-                                    args: None,
-                                    always_run: None,
-                                    fail_fast: None,
-                                    pass_filenames: None,
-                                    description: None,
-                                    language_version: None,
-                                    log_file: None,
-                                    require_serial: None,
-                                    stages: None,
-                                    verbose: None,
-                                    minimum_pre_commit_version: None,
+                                    options: HookOptions {
+                                        alias: None,
+                                        files: None,
+                                        exclude: None,
+                                        types: None,
+                                        types_or: None,
+                                        exclude_types: None,
+                                        additional_dependencies: None,
+                                        args: None,
+                                        always_run: None,
+                                        fail_fast: None,
+                                        pass_filenames: None,
+                                        description: None,
+                                        language_version: None,
+                                        log_file: None,
+                                        require_serial: None,
+                                        stages: None,
+                                        verbose: None,
+                                        minimum_pre_commit_version: None,
+                                    },
                                 },
                             ],
                         },
@@ -720,24 +792,26 @@ mod tests {
                                     name: None,
                                     entry: None,
                                     language: None,
-                                    alias: None,
-                                    files: None,
-                                    exclude: None,
-                                    types: None,
-                                    types_or: None,
-                                    exclude_types: None,
-                                    additional_dependencies: None,
-                                    args: None,
-                                    always_run: None,
-                                    fail_fast: None,
-                                    pass_filenames: None,
-                                    description: None,
-                                    language_version: None,
-                                    log_file: None,
-                                    require_serial: None,
-                                    stages: None,
-                                    verbose: None,
-                                    minimum_pre_commit_version: None,
+                                    options: HookOptions {
+                                        alias: None,
+                                        files: None,
+                                        exclude: None,
+                                        types: None,
+                                        types_or: None,
+                                        exclude_types: None,
+                                        additional_dependencies: None,
+                                        args: None,
+                                        always_run: None,
+                                        fail_fast: None,
+                                        pass_filenames: None,
+                                        description: None,
+                                        language_version: None,
+                                        log_file: None,
+                                        require_serial: None,
+                                        stages: None,
+                                        verbose: None,
+                                        minimum_pre_commit_version: None,
+                                    },
                                 },
                             ],
                         },
@@ -828,24 +902,26 @@ mod tests {
                                     name: "cargo fmt",
                                     entry: "cargo fmt",
                                     language: Rust,
-                                    alias: None,
-                                    files: None,
-                                    exclude: None,
-                                    types: None,
-                                    types_or: None,
-                                    exclude_types: None,
-                                    additional_dependencies: None,
-                                    args: None,
-                                    always_run: None,
-                                    fail_fast: None,
-                                    pass_filenames: None,
-                                    description: None,
-                                    language_version: None,
-                                    log_file: None,
-                                    require_serial: None,
-                                    stages: None,
-                                    verbose: None,
-                                    minimum_pre_commit_version: None,
+                                    options: HookOptions {
+                                        alias: None,
+                                        files: None,
+                                        exclude: None,
+                                        types: None,
+                                        types_or: None,
+                                        exclude_types: None,
+                                        additional_dependencies: None,
+                                        args: None,
+                                        always_run: None,
+                                        fail_fast: None,
+                                        pass_filenames: None,
+                                        description: None,
+                                        language_version: None,
+                                        log_file: None,
+                                        require_serial: None,
+                                        stages: None,
+                                        verbose: None,
+                                        minimum_pre_commit_version: None,
+                                    },
                                 },
                             ],
                         },
@@ -864,6 +940,193 @@ mod tests {
         "###);
     }
 
+    #[test]
+    fn meta_hooks() {
+        // Invalid rev
+        let yaml = indoc::indoc! { r"
+            repos:
+              - repo: meta
+                rev: v1.0.0
+                hooks:
+                  - name: typos
+                    alias: typo
+        "};
+        let result = serde_yaml::from_str::<ConfigWire>(yaml);
+        insta::assert_debug_snapshot!(result, @r###"
+        Err(
+            Error("repos: Invalid meta repo: unknown field `rev`, expected `hooks`", line: 2, column: 3),
+        )
+        "###);
+
+        // Invalid meta hook id
+        let yaml = indoc::indoc! { r"
+            repos:
+              - repo: meta
+                hooks:
+                  - id: hello
+        "};
+        let result = serde_yaml::from_str::<ConfigWire>(yaml);
+        insta::assert_debug_snapshot!(result, @r###"
+        Err(
+            Error("repos: Invalid meta repo: Unknown meta hook id", line: 2, column: 3),
+        )
+        "###);
+
+        // Invalid language
+        let yaml = indoc::indoc! { r"
+            repos:
+              - repo: meta
+                hooks:
+                  - id: check-hooks-apply
+                    language: python
+        "};
+        let result = serde_yaml::from_str::<ConfigWire>(yaml);
+        insta::assert_debug_snapshot!(result, @r###"
+        Err(
+            Error("repos: Invalid meta repo: language must be system for meta hook", line: 2, column: 3),
+        )
+        "###);
+
+        // Invalid entry
+        let yaml = indoc::indoc! { r"
+            repos:
+              - repo: meta
+                hooks:
+                  - id: check-hooks-apply
+                    entry: echo hell world
+        "};
+        let result = serde_yaml::from_str::<ConfigWire>(yaml);
+        insta::assert_debug_snapshot!(result, @r###"
+        Err(
+            Error("repos: Invalid meta repo: entry is not allowed for meta hook", line: 2, column: 3),
+        )
+        "###);
+
+        // Valid meta hook
+        let yaml = indoc::indoc! { r"
+            repos:
+              - repo: meta
+                hooks:
+                  - id: check-hooks-apply
+                  - id: check-useless-excludes
+                  - id: identify
+        "};
+        let result = serde_yaml::from_str::<ConfigWire>(yaml);
+        insta::assert_debug_snapshot!(result, @r###"
+        Ok(
+            ConfigWire {
+                repos: [
+                    Meta(
+                        ConfigMetaRepo {
+                            repo: "meta",
+                            hooks: [
+                                ConfigMetaHook(
+                                    ManifestHook {
+                                        id: "check-hooks-apply",
+                                        name: "Check hooks apply",
+                                        entry: "a",
+                                        language: System,
+                                        options: HookOptions {
+                                            alias: None,
+                                            files: Some(
+                                                "^\\.pre-commit-config\\.yaml$",
+                                            ),
+                                            exclude: None,
+                                            types: None,
+                                            types_or: None,
+                                            exclude_types: None,
+                                            additional_dependencies: None,
+                                            args: None,
+                                            always_run: None,
+                                            fail_fast: None,
+                                            pass_filenames: None,
+                                            description: None,
+                                            language_version: None,
+                                            log_file: None,
+                                            require_serial: None,
+                                            stages: None,
+                                            verbose: None,
+                                            minimum_pre_commit_version: None,
+                                        },
+                                    },
+                                ),
+                                ConfigMetaHook(
+                                    ManifestHook {
+                                        id: "check-useless-excludes",
+                                        name: "Check useless excludes",
+                                        entry: "a",
+                                        language: System,
+                                        options: HookOptions {
+                                            alias: None,
+                                            files: Some(
+                                                "^\\.pre-commit-config\\.yaml$",
+                                            ),
+                                            exclude: None,
+                                            types: None,
+                                            types_or: None,
+                                            exclude_types: None,
+                                            additional_dependencies: None,
+                                            args: None,
+                                            always_run: None,
+                                            fail_fast: None,
+                                            pass_filenames: None,
+                                            description: None,
+                                            language_version: None,
+                                            log_file: None,
+                                            require_serial: None,
+                                            stages: None,
+                                            verbose: None,
+                                            minimum_pre_commit_version: None,
+                                        },
+                                    },
+                                ),
+                                ConfigMetaHook(
+                                    ManifestHook {
+                                        id: "identify",
+                                        name: "identify",
+                                        entry: "a",
+                                        language: System,
+                                        options: HookOptions {
+                                            alias: None,
+                                            files: None,
+                                            exclude: None,
+                                            types: None,
+                                            types_or: None,
+                                            exclude_types: None,
+                                            additional_dependencies: None,
+                                            args: None,
+                                            always_run: None,
+                                            fail_fast: None,
+                                            pass_filenames: None,
+                                            description: None,
+                                            language_version: None,
+                                            log_file: None,
+                                            require_serial: None,
+                                            stages: None,
+                                            verbose: Some(
+                                                true,
+                                            ),
+                                            minimum_pre_commit_version: None,
+                                        },
+                                    },
+                                ),
+                            ],
+                        },
+                    ),
+                ],
+                default_install_hook_types: None,
+                default_language_version: None,
+                default_stages: None,
+                files: None,
+                exclude: None,
+                fail_fast: None,
+                minimum_pre_commit_version: None,
+                ci: None,
+            },
+        )
+        "###);
+    }
+
     #[test]
     fn test_read_config() -> Result<()> {
         let config = read_config(Path::new("tests/files/uv-pre-commit-config.yaml"))?;
diff --git a/src/hook.rs b/src/hook.rs
index 97704d5..9ad5ebb 100644
--- a/src/hook.rs
+++ b/src/hook.rs
@@ -13,8 +13,8 @@ use tracing::{debug, error};
 use url::Url;
 
 use crate::config::{
-    self, read_config, read_manifest, ConfigLocalHook, ConfigRemoteHook, ConfigRepo, ConfigWire,
-    Language, ManifestHook, Stage, CONFIG_FILE, MANIFEST_FILE,
+    self, read_config, read_manifest, ConfigLocalHook, ConfigMetaHook, ConfigRemoteHook,
+    ConfigRepo, ConfigWire, Language, ManifestHook, Stage, CONFIG_FILE, MANIFEST_FILE,
 };
 use crate::fs::{Simplified, CWD};
 use crate::languages::DEFAULT_VERSION;
@@ -47,7 +47,9 @@ pub enum Repo {
     Local {
         hooks: Vec<ManifestHook>,
     },
-    Meta,
+    Meta {
+        hooks: Vec<ManifestHook>,
+    },
 }
 
 impl Repo {
@@ -72,8 +74,11 @@ impl Repo {
         Self::Local { hooks }
     }
 
-    pub fn meta() -> Result<Self, Error> {
-        todo!()
+    /// Construct a meta repo.
+    pub fn meta(hooks: Vec<ConfigMetaHook>) -> Self {
+        Self::Meta {
+            hooks: hooks.into_iter().map(ManifestHook::from).collect(),
+        }
     }
 
     /// Get a hook by id.
@@ -81,16 +86,17 @@ impl Repo {
         let hooks = match self {
             Repo::Remote { ref hooks, .. } => hooks,
             Repo::Local { ref hooks } => hooks,
-            Repo::Meta => return None,
+            Repo::Meta { ref hooks } => hooks,
         };
         hooks.iter().find(|hook| hook.id == id)
     }
 
+    /// Get the path to the repo.
     pub fn path(&self) -> &Path {
         match self {
             Repo::Remote { ref path, .. } => path,
             Repo::Local { .. } => &CWD,
-            Repo::Meta => todo!(),
+            Repo::Meta { .. } => &CWD,
         }
     }
 }
@@ -100,7 +106,7 @@ impl Display for Repo {
         match self {
             Repo::Remote { url, rev, .. } => write!(f, "{url}@{rev}"),
             Repo::Local { .. } => write!(f, "local"),
-            Repo::Meta => write!(f, "meta"),
+            Repo::Meta { .. } => write!(f, "meta"),
         }
     }
 }
@@ -195,8 +201,9 @@ impl Project {
                     let repo = Repo::local(repo.hooks.clone());
                     repos.push(Rc::new(repo));
                 }
-                ConfigRepo::Meta(_) => {
-                    todo!()
+                ConfigRepo::Meta(repo) => {
+                    let repo = Repo::meta(repo.hooks.clone());
+                    repos.push(Rc::new(repo));
                 }
             }
         }
@@ -273,8 +280,18 @@ impl Project {
                         hooks.push(hook);
                     }
                 }
-                ConfigRepo::Meta(_) => {
-                    todo!()
+                ConfigRepo::Meta(repo_config) => {
+                    for hook_config in &repo_config.hooks {
+                        let repo = Rc::clone(repo);
+                        let hook_config = ManifestHook::from(hook_config.clone());
+                        let mut builder = HookBuilder::new(repo, hook_config);
+                        builder.combine(&self.config);
+                        let mut hook = builder.build();
+
+                        let path = hook.repo.path().to_path_buf();
+                        hook = hook.with_path(path);
+                        hooks.push(hook);
+                    }
                 }
             }
         }
@@ -303,36 +320,6 @@ impl HookBuilder {
 
     /// Update the hook from the project level hook configuration.
     fn update(&mut self, config: &ConfigRemoteHook) -> &mut Self {
-        macro_rules! update_if_some {
-            ($($field:ident),* $(,)?) => {
-                $(
-                if config.$field.is_some() {
-                    self.config.$field.clone_from(&config.$field);
-                }
-                )*
-            };
-        }
-        update_if_some!(
-            alias,
-            files,
-            exclude,
-            types,
-            types_or,
-            exclude_types,
-            additional_dependencies,
-            args,
-            always_run,
-            fail_fast,
-            pass_filenames,
-            description,
-            language_version,
-            log_file,
-            require_serial,
-            stages,
-            verbose,
-            minimum_pre_commit_version,
-        );
-
         if let Some(name) = &config.name {
             self.config.name.clone_from(name);
         }
@@ -343,62 +330,64 @@ impl HookBuilder {
             self.config.language.clone_from(language);
         }
 
+        self.config.options.update(&config.options);
+
         self
     }
 
     /// Combine the hook configuration with the project level hook configuration.
     fn combine(&mut self, config: &ConfigWire) {
+        let options = &mut self.config.options;
         let language = self.config.language;
-        if self.config.language_version.is_none() {
-            self.config.language_version = config
+        if options.language_version.is_none() {
+            options.language_version = config
                 .default_language_version
                 .as_ref()
                 .and_then(|v| v.get(&language).cloned());
         }
-        if self.config.language_version.is_none() {
-            self.config.language_version = Some(language.default_version().to_string());
+        if options.language_version.is_none() {
+            options.language_version = Some(language.default_version().to_string());
         }
 
-        if self.config.stages.is_none() {
-            self.config.stages.clone_from(&config.default_stages);
+        if options.stages.is_none() {
+            options.stages.clone_from(&config.default_stages);
         }
     }
 
     /// Fill in the default values for the hook configuration.
     fn fill_in_defaults(&mut self) {
-        self.config
+        let options = &mut self.config.options;
+        options
             .language_version
             .get_or_insert(DEFAULT_VERSION.to_string());
-        self.config.alias.get_or_insert(String::new());
-        self.config.args.get_or_insert(Vec::new());
-        self.config.types.get_or_insert(vec!["file".to_string()]);
-        self.config.types_or.get_or_insert(Vec::new());
-        self.config.exclude_types.get_or_insert(Vec::new());
-        self.config.always_run.get_or_insert(false);
-        self.config.fail_fast.get_or_insert(false);
-        self.config.pass_filenames.get_or_insert(true);
-        self.config.require_serial.get_or_insert(false);
-        self.config.verbose.get_or_insert(false);
-        self.config
+        options.alias.get_or_insert(String::new());
+        options.args.get_or_insert(Vec::new());
+        options.types.get_or_insert(vec!["file".to_string()]);
+        options.types_or.get_or_insert(Vec::new());
+        options.exclude_types.get_or_insert(Vec::new());
+        options.always_run.get_or_insert(false);
+        options.fail_fast.get_or_insert(false);
+        options.pass_filenames.get_or_insert(true);
+        options.require_serial.get_or_insert(false);
+        options.verbose.get_or_insert(false);
+        options
             .stages
             .get_or_insert(Stage::value_variants().to_vec());
-        self.config
-            .additional_dependencies
-            .get_or_insert(Vec::new());
+        options.additional_dependencies.get_or_insert(Vec::new());
     }
 
     /// Check the hook configuration.
     fn check(&self) {
         let language = self.config.language;
         if language.environment_dir().is_none() {
-            if self.config.language_version != Some(DEFAULT_VERSION.to_string()) {
+            if self.config.options.language_version != Some(DEFAULT_VERSION.to_string()) {
                 warn_user!(
                     "Language {} does not need environment, but language_version is set",
                     language
                 );
             }
 
-            if self.config.additional_dependencies.is_some() {
+            if self.config.options.additional_dependencies.is_some() {
                 warn_user!(
                     "Language {} does not need environment, but additional_dependencies is set",
                     language
@@ -412,6 +401,7 @@ impl HookBuilder {
         self.check();
         self.fill_in_defaults();
 
+        let options = self.config.options;
         Hook {
             repo: self.repo,
             path: None,
@@ -419,30 +409,26 @@ impl HookBuilder {
             name: self.config.name,
             entry: self.config.entry,
             language: self.config.language,
-            alias: self.config.alias.expect("alias not set"),
-            files: self.config.files,
-            exclude: self.config.exclude,
-            types: self.config.types.expect("types not set"),
-            types_or: self.config.types_or.expect("types_or not set"),
-            exclude_types: self.config.exclude_types.expect("exclude_types not set"),
-            additional_dependencies: self
-                .config
+            alias: options.alias.expect("alias not set"),
+            files: options.files,
+            exclude: options.exclude,
+            types: options.types.expect("types not set"),
+            types_or: options.types_or.expect("types_or not set"),
+            exclude_types: options.exclude_types.expect("exclude_types not set"),
+            additional_dependencies: options
                 .additional_dependencies
                 .expect("additional_dependencies should not be None"),
-            args: self.config.args.expect("args not set"),
-            always_run: self.config.always_run.expect("always_run not set"),
-            fail_fast: self.config.fail_fast.expect("fail_fast not set"),
-            pass_filenames: self.config.pass_filenames.expect("pass_filenames not set"),
-            description: self.config.description,
-            language_version: self
-                .config
-                .language_version
-                .expect("language_version not set"),
-            log_file: self.config.log_file,
-            require_serial: self.config.require_serial.expect("require_serial not set"),
-            stages: self.config.stages.expect("stages not set"),
-            verbose: self.config.verbose.expect("verbose not set"),
-            minimum_pre_commit_version: self.config.minimum_pre_commit_version,
+            args: options.args.expect("args not set"),
+            always_run: options.always_run.expect("always_run not set"),
+            fail_fast: options.fail_fast.expect("fail_fast not set"),
+            pass_filenames: options.pass_filenames.expect("pass_filenames not set"),
+            description: options.description,
+            language_version: options.language_version.expect("language_version not set"),
+            log_file: options.log_file,
+            require_serial: options.require_serial.expect("require_serial not set"),
+            stages: options.stages.expect("stages not set"),
+            verbose: options.verbose.expect("verbose not set"),
+            minimum_pre_commit_version: options.minimum_pre_commit_version,
         }
     }
 }


Test patch:
diff --git a/src/snapshots/prefligit__config__tests__read_config.snap b/src/snapshots/prefligit__config__tests__read_config.snap
index c9a2299..6d141bf 100644
--- a/src/snapshots/prefligit__config__tests__read_config.snap
+++ b/src/snapshots/prefligit__config__tests__read_config.snap
@@ -28,24 +28,26 @@ ConfigWire {
                         name: None,
                         entry: None,
                         language: None,
-                        alias: None,
-                        files: None,
-                        exclude: None,
-                        types: None,
-                        types_or: None,
-                        exclude_types: None,
-                        additional_dependencies: None,
-                        args: None,
-                        always_run: None,
-                        fail_fast: None,
-                        pass_filenames: None,
-                        description: None,
-                        language_version: None,
-                        log_file: None,
-                        require_serial: None,
-                        stages: None,
-                        verbose: None,
-                        minimum_pre_commit_version: None,
+                        options: HookOptions {
+                            alias: None,
+                            files: None,
+                            exclude: None,
+                            types: None,
+                            types_or: None,
+                            exclude_types: None,
+                            additional_dependencies: None,
+                            args: None,
+                            always_run: None,
+                            fail_fast: None,
+                            pass_filenames: None,
+                            description: None,
+                            language_version: None,
+                            log_file: None,
+                            require_serial: None,
+                            stages: None,
+                            verbose: None,
+                            minimum_pre_commit_version: None,
+                        },
                     },
                 ],
             },
@@ -74,24 +76,26 @@ ConfigWire {
                         name: None,
                         entry: None,
                         language: None,
-                        alias: None,
-                        files: None,
-                        exclude: None,
-                        types: None,
-                        types_or: None,
-                        exclude_types: None,
-                        additional_dependencies: None,
-                        args: None,
-                        always_run: None,
-                        fail_fast: None,
-                        pass_filenames: None,
-                        description: None,
-                        language_version: None,
-                        log_file: None,
-                        require_serial: None,
-                        stages: None,
-                        verbose: None,
-                        minimum_pre_commit_version: None,
+                        options: HookOptions {
+                            alias: None,
+                            files: None,
+                            exclude: None,
+                            types: None,
+                            types_or: None,
+                            exclude_types: None,
+                            additional_dependencies: None,
+                            args: None,
+                            always_run: None,
+                            fail_fast: None,
+                            pass_filenames: None,
+                            description: None,
+                            language_version: None,
+                            log_file: None,
+                            require_serial: None,
+                            stages: None,
+                            verbose: None,
+                            minimum_pre_commit_version: None,
+                        },
                     },
                 ],
             },
@@ -105,30 +109,32 @@ ConfigWire {
                         name: "cargo fmt",
                         entry: "cargo fmt --",
                         language: System,
-                        alias: None,
-                        files: None,
-                        exclude: None,
-                        types: Some(
-                            [
-                                "rust",
-                            ],
-                        ),
-                        types_or: None,
-                        exclude_types: None,
-                        additional_dependencies: None,
-                        args: None,
-                        always_run: None,
-                        fail_fast: None,
-                        pass_filenames: Some(
-                            false,
-                        ),
-                        description: None,
-                        language_version: None,
-                        log_file: None,
-                        require_serial: None,
-                        stages: None,
-                        verbose: None,
-                        minimum_pre_commit_version: None,
+                        options: HookOptions {
+                            alias: None,
+                            files: None,
+                            exclude: None,
+                            types: Some(
+                                [
+                                    "rust",
+                                ],
+                            ),
+                            types_or: None,
+                            exclude_types: None,
+                            additional_dependencies: None,
+                            args: None,
+                            always_run: None,
+                            fail_fast: None,
+                            pass_filenames: Some(
+                                false,
+                            ),
+                            description: None,
+                            language_version: None,
+                            log_file: None,
+                            require_serial: None,
+                            stages: None,
+                            verbose: None,
+                            minimum_pre_commit_version: None,
+                        },
                     },
                 ],
             },
@@ -142,32 +148,34 @@ ConfigWire {
                         name: "cargo dev generate-all",
                         entry: "cargo dev generate-all",
                         language: System,
-                        alias: None,
-                        files: Some(
-                            "^crates/(uv-cli|uv-settings)/",
-                        ),
-                        exclude: None,
-                        types: Some(
-                            [
-                                "rust",
-                            ],
-                        ),
-                        types_or: None,
-                        exclude_types: None,
-                        additional_dependencies: None,
-                        args: None,
-                        always_run: None,
-                        fail_fast: None,
-                        pass_filenames: Some(
-                            false,
-                        ),
-                        description: None,
-                        language_version: None,
-                        log_file: None,
-                        require_serial: None,
-                        stages: None,
-                        verbose: None,
-                        minimum_pre_commit_version: None,
+                        options: HookOptions {
+                            alias: None,
+                            files: Some(
+                                "^crates/(uv-cli|uv-settings)/",
+                            ),
+                            exclude: None,
+                            types: Some(
+                                [
+                                    "rust",
+                                ],
+                            ),
+                            types_or: None,
+                            exclude_types: None,
+                            additional_dependencies: None,
+                            args: None,
+                            always_run: None,
+                            fail_fast: None,
+                            pass_filenames: Some(
+                                false,
+                            ),
+                            description: None,
+                            language_version: None,
+                            log_file: None,
+                            require_serial: None,
+                            stages: None,
+                            verbose: None,
+                            minimum_pre_commit_version: None,
+                        },
                     },
                 ],
             },
@@ -196,29 +204,31 @@ ConfigWire {
                         name: None,
                         entry: None,
                         language: None,
-                        alias: None,
-                        files: None,
-                        exclude: None,
-                        types: None,
-                        types_or: Some(
-                            [
-                                "yaml",
-                                "json5",
-                            ],
-                        ),
-                        exclude_types: None,
-                        additional_dependencies: None,
-                        args: None,
-                        always_run: None,
-                        fail_fast: None,
-                        pass_filenames: None,
-                        description: None,
-                        language_version: None,
-                        log_file: None,
-                        require_serial: None,
-                        stages: None,
-                        verbose: None,
-                        minimum_pre_commit_version: None,
+                        options: HookOptions {
+                            alias: None,
+                            files: None,
+                            exclude: None,
+                            types: None,
+                            types_or: Some(
+                                [
+                                    "yaml",
+                                    "json5",
+                                ],
+                            ),
+                            exclude_types: None,
+                            additional_dependencies: None,
+                            args: None,
+                            always_run: None,
+                            fail_fast: None,
+                            pass_filenames: None,
+                            description: None,
+                            language_version: None,
+                            log_file: None,
+                            require_serial: None,
+                            stages: None,
+                            verbose: None,
+                            minimum_pre_commit_version: None,
+                        },
                     },
                 ],
             },
@@ -247,53 +257,57 @@ ConfigWire {
                         name: None,
                         entry: None,
                         language: None,
-                        alias: None,
-                        files: None,
-                        exclude: None,
-                        types: None,
-                        types_or: None,
-                        exclude_types: None,
-                        additional_dependencies: None,
-                        args: None,
-                        always_run: None,
-                        fail_fast: None,
-                        pass_filenames: None,
-                        description: None,
-                        language_version: None,
-                        log_file: None,
-                        require_serial: None,
-                        stages: None,
-                        verbose: None,
-                        minimum_pre_commit_version: None,
+                        options: HookOptions {
+                            alias: None,
+                            files: None,
+                            exclude: None,
+                            types: None,
+                            types_or: None,
+                            exclude_types: None,
+                            additional_dependencies: None,
+                            args: None,
+                            always_run: None,
+                            fail_fast: None,
+                            pass_filenames: None,
+                            description: None,
+                            language_version: None,
+                            log_file: None,
+                            require_serial: None,
+                            stages: None,
+                            verbose: None,
+                            minimum_pre_commit_version: None,
+                        },
                     },
                     ConfigRemoteHook {
                         id: "ruff",
                         name: None,
                         entry: None,
                         language: None,
-                        alias: None,
-                        files: None,
-                        exclude: None,
-                        types: None,
-                        types_or: None,
-                        exclude_types: None,
-                        additional_dependencies: None,
-                        args: Some(
-                            [
-                                "--fix",
-                                "--exit-non-zero-on-fix",
-                            ],
-                        ),
-                        always_run: None,
-                        fail_fast: None,
-                        pass_filenames: None,
-                        description: None,
-                        language_version: None,
-                        log_file: None,
-                        require_serial: None,
-                        stages: None,
-                        verbose: None,
-                        minimum_pre_commit_version: None,
+                        options: HookOptions {
+                            alias: None,
+                            files: None,
+                            exclude: None,
+                            types: None,
+                            types_or: None,
+                            exclude_types: None,
+                            additional_dependencies: None,
+                            args: Some(
+                                [
+                                    "--fix",
+                                    "--exit-non-zero-on-fix",
+                                ],
+                            ),
+                            always_run: None,
+                            fail_fast: None,
+                            pass_filenames: None,
+                            description: None,
+                            language_version: None,
+                            log_file: None,
+                            require_serial: None,
+                            stages: None,
+                            verbose: None,
+                            minimum_pre_commit_version: None,
+                        },
                     },
                 ],
             },
diff --git a/src/snapshots/prefligit__config__tests__read_manifest.snap b/src/snapshots/prefligit__config__tests__read_manifest.snap
index ec5839c..30501ba 100644
--- a/src/snapshots/prefligit__config__tests__read_manifest.snap
+++ b/src/snapshots/prefligit__config__tests__read_manifest.snap
@@ -9,111 +9,117 @@ ManifestWire {
             name: "pip-compile",
             entry: "uv pip compile",
             language: Python,
-            alias: None,
-            files: Some(
-                "^requirements\\.(in|txt)$",
-            ),
-            exclude: None,
-            types: None,
-            types_or: None,
-            exclude_types: None,
-            additional_dependencies: Some(
-                [],
-            ),
-            args: Some(
-                [],
-            ),
-            always_run: None,
-            fail_fast: None,
-            pass_filenames: Some(
-                false,
-            ),
-            description: Some(
-                "Automatically run 'uv pip compile' on your requirements",
-            ),
-            language_version: None,
-            log_file: None,
-            require_serial: None,
-            stages: None,
-            verbose: None,
-            minimum_pre_commit_version: Some(
-                "2.9.2",
-            ),
+            options: HookOptions {
+                alias: None,
+                files: Some(
+                    "^requirements\\.(in|txt)$",
+                ),
+                exclude: None,
+                types: None,
+                types_or: None,
+                exclude_types: None,
+                additional_dependencies: Some(
+                    [],
+                ),
+                args: Some(
+                    [],
+                ),
+                always_run: None,
+                fail_fast: None,
+                pass_filenames: Some(
+                    false,
+                ),
+                description: Some(
+                    "Automatically run 'uv pip compile' on your requirements",
+                ),
+                language_version: None,
+                log_file: None,
+                require_serial: None,
+                stages: None,
+                verbose: None,
+                minimum_pre_commit_version: Some(
+                    "2.9.2",
+                ),
+            },
         },
         ManifestHook {
             id: "uv-lock",
             name: "uv-lock",
             entry: "uv lock",
             language: Python,
-            alias: None,
-            files: Some(
-                "^(uv\\.lock|pyproject\\.toml|uv\\.toml)$",
-            ),
-            exclude: None,
-            types: None,
-            types_or: None,
-            exclude_types: None,
-            additional_dependencies: Some(
-                [],
-            ),
-            args: Some(
-                [],
-            ),
-            always_run: None,
-            fail_fast: None,
-            pass_filenames: Some(
-                false,
-            ),
-            description: Some(
-                "Automatically run 'uv lock' on your project dependencies",
-            ),
-            language_version: None,
-            log_file: None,
-            require_serial: None,
-            stages: None,
-            verbose: None,
-            minimum_pre_commit_version: Some(
-                "2.9.2",
-            ),
+            options: HookOptions {
+                alias: None,
+                files: Some(
+                    "^(uv\\.lock|pyproject\\.toml|uv\\.toml)$",
+                ),
+                exclude: None,
+                types: None,
+                types_or: None,
+                exclude_types: None,
+                additional_dependencies: Some(
+                    [],
+                ),
+                args: Some(
+                    [],
+                ),
+                always_run: None,
+                fail_fast: None,
+                pass_filenames: Some(
+                    false,
+                ),
+                description: Some(
+                    "Automatically run 'uv lock' on your project dependencies",
+                ),
+                language_version: None,
+                log_file: None,
+                require_serial: None,
+                stages: None,
+                verbose: None,
+                minimum_pre_commit_version: Some(
+                    "2.9.2",
+                ),
+            },
         },
         ManifestHook {
             id: "uv-export",
             name: "uv-export",
             entry: "uv export",
             language: Python,
-            alias: None,
-            files: Some(
-                "^uv\\.lock$",
-            ),
-            exclude: None,
-            types: None,
-            types_or: None,
-            exclude_types: None,
-            additional_dependencies: Some(
-                [],
-            ),
-            args: Some(
-                [
-                    "--frozen",
-                    "--output-file=requirements.txt",
-                ],
-            ),
-            always_run: None,
-            fail_fast: None,
-            pass_filenames: Some(
-                false,
-            ),
-            description: Some(
-                "Automatically run 'uv export' on your project dependencies",
-            ),
-            language_version: None,
-            log_file: None,
-            require_serial: None,
-            stages: None,
-            verbose: None,
-            minimum_pre_commit_version: Some(
-                "2.9.2",
-            ),
+            options: HookOptions {
+                alias: None,
+                files: Some(
+                    "^uv\\.lock$",
+                ),
+                exclude: None,
+                types: None,
+                types_or: None,
+                exclude_types: None,
+                additional_dependencies: Some(
+                    [],
+                ),
+                args: Some(
+                    [
+                        "--frozen",
+                        "--output-file=requirements.txt",
+                    ],
+                ),
+                always_run: None,
+                fail_fast: None,
+                pass_filenames: Some(
+                    false,
+                ),
+                description: Some(
+                    "Automatically run 'uv export' on your project dependencies",
+                ),
+                language_version: None,
+                log_file: None,
+                require_serial: None,
+                stages: None,
+                verbose: None,
+                minimum_pre_commit_version: Some(
+                    "2.9.2",
+                ),
+            },
         },
     ],
 }