Skip to content

grip_hook.config

grip_hook.config

Configuration loading.

Precedence, highest first:

  1. Command-line flags.
  2. GRIP_* environment variables.
  3. .grip.toml at the repository root.
  4. [tool.grip] in pyproject.toml at the repository root.
  5. Built-in defaults.

[[paths]] rules (paths in :class:Config) come from the files only. They adjust the pass mark and difficulty for the files a diff touches, after everything above is resolved; see :meth:Config.for_files.

AGENT_PROVIDERS module-attribute

AGENT_PROVIDERS: tuple[tuple[str, str], ...] = (('claude-code', 'claude'), ('codex', 'codex'), ('gemini', 'gemini'))

Coding-agent providers auto tries, in order, with the command each one needs.

PathRule dataclass

One [[paths]] table: a different bar for the files that match it.

Source code in src/grip_hook/config.py
@dataclass(frozen=True, slots=True)
class PathRule:
    """One ``[[paths]]`` table: a different bar for the files that match it."""

    match: tuple[str, ...]
    """Globs, read like ``exclude``. A file matches when any of them does."""

    passing_score: int | None = None
    """Pass mark for diffs that touch a matching file. ``None`` keeps the repository's."""

    difficulty: Difficulty | None = None
    """Difficulty for diffs that touch a matching file. ``None`` keeps the repository's."""

    skip: bool = False
    """Matching files need no quiz; a diff made only of them is not quizzed at all."""

    def matches(self, path: str) -> bool:
        """Whether ``path`` matches any of this rule's globs."""
        return any(path_matches(pattern, path) for pattern in self.match)

    def describe(self) -> str:
        """``services/api/** (passing_score 90, hard)`` style text for display."""
        settings = []
        if self.passing_score is not None:
            settings.append(f"passing_score {self.passing_score}")
        if self.difficulty is not None:
            settings.append(self.difficulty.value)
        if self.skip:
            settings.append("skip")
        return f"{', '.join(self.match)} ({', '.join(settings) or 'no change'})"

match instance-attribute

match: tuple[str, ...]

Globs, read like exclude. A file matches when any of them does.

passing_score class-attribute instance-attribute

passing_score: int | None = None

Pass mark for diffs that touch a matching file. None keeps the repository's.

difficulty class-attribute instance-attribute

difficulty: Difficulty | None = None

Difficulty for diffs that touch a matching file. None keeps the repository's.

skip class-attribute instance-attribute

skip: bool = False

Matching files need no quiz; a diff made only of them is not quizzed at all.

matches

matches(path: str) -> bool

Whether path matches any of this rule's globs.

Source code in src/grip_hook/config.py
def matches(self, path: str) -> bool:
    """Whether ``path`` matches any of this rule's globs."""
    return any(path_matches(pattern, path) for pattern in self.match)

describe

describe() -> str

services/api/** (passing_score 90, hard) style text for display.

Source code in src/grip_hook/config.py
def describe(self) -> str:
    """``services/api/** (passing_score 90, hard)`` style text for display."""
    settings = []
    if self.passing_score is not None:
        settings.append(f"passing_score {self.passing_score}")
    if self.difficulty is not None:
        settings.append(self.difficulty.value)
    if self.skip:
        settings.append("skip")
    return f"{', '.join(self.match)} ({', '.join(settings) or 'no change'})"

Config dataclass

Resolved grip configuration.

Source code in src/grip_hook/config.py
@dataclass(frozen=True, slots=True)
class Config:
    """Resolved grip configuration."""

    passing_score: int = DEFAULT_PASSING_SCORE
    """Minimum Grip Score (0-100) required to let the commit or push through."""

    provider: str = AUTO_PROVIDER
    """Provider name: ``auto``, ``anthropic``, ``openai``, ``ollama``, ``claude-code``,
    ``codex``, ``gemini`` or ``fake``. ``auto`` picks an installed coding agent, else
    ``anthropic``; see :func:`auto_provider`."""

    model: str = ""
    """Model identifier passed to the provider. Empty means the provider's default."""

    effort: str = "medium"
    """Reasoning effort for providers that support it. ``none`` disables the parameter."""

    api_key_env: str = ""
    """Environment variable holding the API key. Empty means the provider's default."""

    base_url: str = ""
    """Override the provider's API base URL (Ollama, proxies, OpenAI-compatible servers)."""

    difficulty: Difficulty = Difficulty.NORMAL
    """How demanding the questions and grading are."""

    max_diff_bytes: int = 200_000
    """Diffs larger than this are truncated before being sent to the provider."""

    exclude: tuple[str, ...] = DEFAULT_EXCLUDES
    """Path globs excluded from the diff (lockfiles, generated code)."""

    include_commit_messages: bool = True
    """Send the messages of the commits being quizzed (push, ``--unpushed``, ``--branch``,
    ``--range``) to the model with the diff, as data, so it can ask and grade "why"."""

    skip_paths: tuple[str, ...] = ()
    """Path globs (``docs/**``, ``*.md``) with nothing to quiz about: a diff that touches
    only these paths is skipped. Unlike ``exclude``, a diff that also touches other files
    is quizzed whole."""

    min_changed_lines: int = 0
    """Skip diffs that add and remove fewer lines than this in total. ``0`` disables."""

    remember_passes_hours: float = 24.0
    """How long a passed diff stays valid without re-quizzing. ``0`` disables."""

    cache_hours: float = 24.0
    """How long the questions and boards written for a diff are reused when the same diff
    is quizzed again, so a retry after a failed quiz costs one grading call. ``0`` asks
    the model every time; ``--fresh`` does so once."""

    retry_questions: str = "same"
    """What a retry after a failed quiz asks: ``same`` reuses the questions (or board)
    written for the diff, within ``cache_hours``; ``new`` forgets them on a failure, so
    the next try on the same diff gets new ones from the model. Either way the grading
    feedback says where to look, not what the rubric expected."""

    hook_mode: str = "quiz"
    """What the git hook asks for: ``quiz`` (questions in the terminal) or ``whiteboard``
    (defend a board of the change in the browser, as ``grip whiteboard --defend`` does).
    The whiteboard falls back to the terminal quiz when no browser can be opened."""

    require_tty: bool = False
    """Fail instead of skipping when no interactive terminal is available."""

    fail_open: bool = False
    """Let the commit or push through when the provider errors out instead of blocking."""

    timeout: float = 120.0
    """Per-request timeout in seconds for provider calls."""

    paths: tuple[PathRule, ...] = ()
    """``[[paths]]`` rules: a pass mark, difficulty or skip for part of the repository."""

    def skips(self, files: Iterable[str]) -> bool:
        """Whether every one of ``files`` matches a ``skip = true`` rule (and there is one)."""
        files = list(files)
        skipping = [r for r in self.paths if r.skip]
        return bool(files and skipping) and all(any(r.matches(f) for r in skipping) for f in files)

    def for_files(self, files: Iterable[str]) -> Config:
        """This configuration with the ``[[paths]]`` rules for ``files`` applied.

        Each file gets the strictest pass mark and the hardest difficulty of the rules it
        matches, or the repository's own when no matching rule sets one; files matched by a
        ``skip`` rule do not count. The diff then takes the strictest over its files, so a
        rule that lowers the bar for ``docs/`` never lowers it for code in the same diff.
        """
        if not self.paths:
            return self
        scores: list[int] = []
        levels: list[Difficulty] = []
        for path in files:
            matched = [r for r in self.paths if r.matches(path)]
            if any(r.skip for r in matched):
                continue
            rule_scores = [r.passing_score for r in matched if r.passing_score is not None]
            rule_levels = [r.difficulty for r in matched if r.difficulty is not None]
            scores.append(max(rule_scores) if rule_scores else self.passing_score)
            levels.append(
                max(rule_levels, key=_DIFFICULTY_RANK.__getitem__)
                if rule_levels
                else self.difficulty
            )
        if not scores:
            return self
        return replace(
            self,
            passing_score=max(scores),
            difficulty=max(levels, key=_DIFFICULTY_RANK.__getitem__),
        )

    def validate(self) -> Config:
        """Raise :class:`ConfigError` for out-of-range values and return ``self``."""
        if not 0 <= self.passing_score <= 100:
            raise ConfigError(f"passing_score must be between 0 and 100, got {self.passing_score}")
        if self.max_diff_bytes <= 0:
            raise ConfigError("max_diff_bytes must be positive")
        if self.min_changed_lines < 0:
            raise ConfigError("min_changed_lines must be zero or positive")
        if self.remember_passes_hours < 0:
            raise ConfigError("remember_passes_hours must be zero or positive")
        if self.cache_hours < 0:
            raise ConfigError("cache_hours must be zero or positive")
        if self.timeout <= 0:
            raise ConfigError("timeout must be positive")
        if not self.provider:
            raise ConfigError("provider must not be empty")
        if self.hook_mode not in HOOK_MODES:
            allowed = ", ".join(HOOK_MODES)
            raise ConfigError(f"hook_mode must be one of {allowed}, got {self.hook_mode!r}")
        for rule in self.paths:
            if rule.passing_score is not None and not 0 <= rule.passing_score <= 100:
                raise ConfigError(
                    f"passing_score in [[paths]] {', '.join(rule.match)} must be between "
                    f"0 and 100, got {rule.passing_score}"
                )
        if self.retry_questions not in RETRY_QUESTIONS:
            allowed = ", ".join(RETRY_QUESTIONS)
            raise ConfigError(
                f"retry_questions must be one of {allowed}, got {self.retry_questions!r}"
            )
        return self

    @property
    def provider_name(self) -> str:
        """The provider that will actually run: ``auto`` resolved on this machine."""
        name = self.provider.lower()
        return auto_provider() if name == AUTO_PROVIDER else name

passing_score class-attribute instance-attribute

passing_score: int = DEFAULT_PASSING_SCORE

Minimum Grip Score (0-100) required to let the commit or push through.

provider class-attribute instance-attribute

provider: str = AUTO_PROVIDER

Provider name: auto, anthropic, openai, ollama, claude-code, codex, gemini or fake. auto picks an installed coding agent, else anthropic; see :func:auto_provider.

model class-attribute instance-attribute

model: str = ''

Model identifier passed to the provider. Empty means the provider's default.

effort class-attribute instance-attribute

effort: str = 'medium'

Reasoning effort for providers that support it. none disables the parameter.

api_key_env class-attribute instance-attribute

api_key_env: str = ''

Environment variable holding the API key. Empty means the provider's default.

base_url class-attribute instance-attribute

base_url: str = ''

Override the provider's API base URL (Ollama, proxies, OpenAI-compatible servers).

difficulty class-attribute instance-attribute

difficulty: Difficulty = Difficulty.NORMAL

How demanding the questions and grading are.

max_diff_bytes class-attribute instance-attribute

max_diff_bytes: int = 200000

Diffs larger than this are truncated before being sent to the provider.

exclude class-attribute instance-attribute

exclude: tuple[str, ...] = DEFAULT_EXCLUDES

Path globs excluded from the diff (lockfiles, generated code).

include_commit_messages class-attribute instance-attribute

include_commit_messages: bool = True

Send the messages of the commits being quizzed (push, --unpushed, --branch, --range) to the model with the diff, as data, so it can ask and grade "why".

skip_paths class-attribute instance-attribute

skip_paths: tuple[str, ...] = ()

Path globs (docs/**, *.md) with nothing to quiz about: a diff that touches only these paths is skipped. Unlike exclude, a diff that also touches other files is quizzed whole.

min_changed_lines class-attribute instance-attribute

min_changed_lines: int = 0

Skip diffs that add and remove fewer lines than this in total. 0 disables.

remember_passes_hours class-attribute instance-attribute

remember_passes_hours: float = 24.0

How long a passed diff stays valid without re-quizzing. 0 disables.

cache_hours class-attribute instance-attribute

cache_hours: float = 24.0

How long the questions and boards written for a diff are reused when the same diff is quizzed again, so a retry after a failed quiz costs one grading call. 0 asks the model every time; --fresh does so once.

retry_questions class-attribute instance-attribute

retry_questions: str = 'same'

What a retry after a failed quiz asks: same reuses the questions (or board) written for the diff, within cache_hours; new forgets them on a failure, so the next try on the same diff gets new ones from the model. Either way the grading feedback says where to look, not what the rubric expected.

hook_mode class-attribute instance-attribute

hook_mode: str = 'quiz'

What the git hook asks for: quiz (questions in the terminal) or whiteboard (defend a board of the change in the browser, as grip whiteboard --defend does). The whiteboard falls back to the terminal quiz when no browser can be opened.

require_tty class-attribute instance-attribute

require_tty: bool = False

Fail instead of skipping when no interactive terminal is available.

fail_open class-attribute instance-attribute

fail_open: bool = False

Let the commit or push through when the provider errors out instead of blocking.

timeout class-attribute instance-attribute

timeout: float = 120.0

Per-request timeout in seconds for provider calls.

paths class-attribute instance-attribute

paths: tuple[PathRule, ...] = ()

[[paths]] rules: a pass mark, difficulty or skip for part of the repository.

provider_name property

provider_name: str

The provider that will actually run: auto resolved on this machine.

skips

skips(files: Iterable[str]) -> bool

Whether every one of files matches a skip = true rule (and there is one).

Source code in src/grip_hook/config.py
def skips(self, files: Iterable[str]) -> bool:
    """Whether every one of ``files`` matches a ``skip = true`` rule (and there is one)."""
    files = list(files)
    skipping = [r for r in self.paths if r.skip]
    return bool(files and skipping) and all(any(r.matches(f) for r in skipping) for f in files)

for_files

for_files(files: Iterable[str]) -> Config

This configuration with the [[paths]] rules for files applied.

Each file gets the strictest pass mark and the hardest difficulty of the rules it matches, or the repository's own when no matching rule sets one; files matched by a skip rule do not count. The diff then takes the strictest over its files, so a rule that lowers the bar for docs/ never lowers it for code in the same diff.

Source code in src/grip_hook/config.py
def for_files(self, files: Iterable[str]) -> Config:
    """This configuration with the ``[[paths]]`` rules for ``files`` applied.

    Each file gets the strictest pass mark and the hardest difficulty of the rules it
    matches, or the repository's own when no matching rule sets one; files matched by a
    ``skip`` rule do not count. The diff then takes the strictest over its files, so a
    rule that lowers the bar for ``docs/`` never lowers it for code in the same diff.
    """
    if not self.paths:
        return self
    scores: list[int] = []
    levels: list[Difficulty] = []
    for path in files:
        matched = [r for r in self.paths if r.matches(path)]
        if any(r.skip for r in matched):
            continue
        rule_scores = [r.passing_score for r in matched if r.passing_score is not None]
        rule_levels = [r.difficulty for r in matched if r.difficulty is not None]
        scores.append(max(rule_scores) if rule_scores else self.passing_score)
        levels.append(
            max(rule_levels, key=_DIFFICULTY_RANK.__getitem__)
            if rule_levels
            else self.difficulty
        )
    if not scores:
        return self
    return replace(
        self,
        passing_score=max(scores),
        difficulty=max(levels, key=_DIFFICULTY_RANK.__getitem__),
    )

validate

validate() -> Config

Raise :class:ConfigError for out-of-range values and return self.

Source code in src/grip_hook/config.py
def validate(self) -> Config:
    """Raise :class:`ConfigError` for out-of-range values and return ``self``."""
    if not 0 <= self.passing_score <= 100:
        raise ConfigError(f"passing_score must be between 0 and 100, got {self.passing_score}")
    if self.max_diff_bytes <= 0:
        raise ConfigError("max_diff_bytes must be positive")
    if self.min_changed_lines < 0:
        raise ConfigError("min_changed_lines must be zero or positive")
    if self.remember_passes_hours < 0:
        raise ConfigError("remember_passes_hours must be zero or positive")
    if self.cache_hours < 0:
        raise ConfigError("cache_hours must be zero or positive")
    if self.timeout <= 0:
        raise ConfigError("timeout must be positive")
    if not self.provider:
        raise ConfigError("provider must not be empty")
    if self.hook_mode not in HOOK_MODES:
        allowed = ", ".join(HOOK_MODES)
        raise ConfigError(f"hook_mode must be one of {allowed}, got {self.hook_mode!r}")
    for rule in self.paths:
        if rule.passing_score is not None and not 0 <= rule.passing_score <= 100:
            raise ConfigError(
                f"passing_score in [[paths]] {', '.join(rule.match)} must be between "
                f"0 and 100, got {rule.passing_score}"
            )
    if self.retry_questions not in RETRY_QUESTIONS:
        allowed = ", ".join(RETRY_QUESTIONS)
        raise ConfigError(
            f"retry_questions must be one of {allowed}, got {self.retry_questions!r}"
        )
    return self

Resolved dataclass

The effective :class:Config, and where each of its values came from.

Source code in src/grip_hook/config.py
@dataclass(frozen=True, slots=True)
class Resolved:
    """The effective :class:`Config`, and where each of its values came from."""

    config: Config
    sources: dict[str, str]
    """Option name to the layer that set it last: ``default``, ``pyproject.toml``,
    ``.grip.toml``, ``env GRIP_<NAME>`` or ``flag --<name>``."""
    files: tuple[Path, ...]
    """The configuration files that were read, lowest precedence first."""

sources instance-attribute

sources: dict[str, str]

Option name to the layer that set it last: default, pyproject.toml, .grip.toml, env GRIP_<NAME> or flag --<name>.

files instance-attribute

files: tuple[Path, ...]

The configuration files that were read, lowest precedence first.

auto_provider

auto_provider() -> str

What provider = "auto" means on this machine.

A coding agent that is installed (and so, usually, signed in) needs no API key and adds no bill, so the first of Claude Code, Codex and Gemini CLI on PATH wins; without one, the Anthropic API.

Source code in src/grip_hook/config.py
def auto_provider() -> str:
    """What ``provider = "auto"`` means on this machine.

    A coding agent that is installed (and so, usually, signed in) needs no API key and adds
    no bill, so the first of Claude Code, Codex and Gemini CLI on ``PATH`` wins; without
    one, the Anthropic API.
    """
    for name, command in AGENT_PROVIDERS:
        if shutil.which(command):
            return name
    return "anthropic"

path_matches

path_matches(pattern: str, path: str) -> bool

Whether path or a directory above it matches pattern.

path is /-separated and relative to the repository root. Matching the directories too is what lets services/api cover every file under it.

Source code in src/grip_hook/config.py
def path_matches(pattern: str, path: str) -> bool:
    """Whether ``path`` or a directory above it matches ``pattern``.

    ``path`` is ``/``-separated and relative to the repository root. Matching the
    directories too is what lets ``services/api`` cover every file under it.
    """
    regex = _glob_regex(pattern)
    parts = path.split("/")
    return any(regex.fullmatch("/".join(parts[:n])) for n in range(len(parts), 0, -1))

resolve_config

resolve_config(root: Path | None, *, env: dict[str, str] | None = None, overrides: dict[str, Any] | None = None) -> Resolved

Build the effective :class:Config for a repository and record each value's source.

Parameters:

Name Type Description Default
root Path | None

Repository root (where .grip.toml / pyproject.toml live), or None.

required
env dict[str, str] | None

Environment mapping. Defaults to :data:os.environ.

None
overrides dict[str, Any] | None

Values from command-line flags. None entries are ignored.

None
Source code in src/grip_hook/config.py
def resolve_config(
    root: Path | None,
    *,
    env: dict[str, str] | None = None,
    overrides: dict[str, Any] | None = None,
) -> Resolved:
    """Build the effective :class:`Config` for a repository and record each value's source.

    Args:
        root: Repository root (where ``.grip.toml`` / ``pyproject.toml`` live), or ``None``.
        env: Environment mapping. Defaults to :data:`os.environ`.
        overrides: Values from command-line flags. ``None`` entries are ignored.
    """
    cfg = Config()
    sources = dict.fromkeys(_FIELD_NAMES, DEFAULT_SOURCE)
    files: list[Path] = []

    def layer(raw: dict[str, Any], where: str, label: Callable[[str], str]) -> None:
        nonlocal cfg
        cfg = _apply(cfg, raw, where)
        for key in raw:
            sources[key.replace("-", "_")] = label(key)

    if root is not None:
        pyproject = root / "pyproject.toml"
        if pyproject.is_file():
            tool = _read_toml(pyproject).get("tool", {})
            if isinstance(tool, dict) and isinstance(tool.get("grip"), dict):
                files.append(pyproject)
                layer(tool["grip"], str(pyproject), lambda _: "pyproject.toml")
        grip_toml = root / ".grip.toml"
        if grip_toml.is_file():
            files.append(grip_toml)
            layer(_read_toml(grip_toml), str(grip_toml), lambda _: ".grip.toml")

    environ = os.environ if env is None else env
    env_keys = {
        key[len(ENV_PREFIX) :].lower(): key
        for key in environ
        if key.startswith(ENV_PREFIX)
        and key[len(ENV_PREFIX) :].lower() in _FIELD_NAMES - _FILE_ONLY
    }
    layer(
        {name: environ[key] for name, key in env_keys.items()},
        "environment",
        lambda name: f"env {env_keys[name]}",
    )

    if overrides:
        layer(
            {k: v for k, v in overrides.items() if v is not None},
            "command line",
            lambda name: f"flag {_FLAGS.get(name, '--' + name.replace('_', '-'))}",
        )
    return Resolved(cfg.validate(), sources, tuple(files))

load_config

load_config(root: Path | None, *, env: dict[str, str] | None = None, overrides: dict[str, Any] | None = None) -> Config

Build the effective :class:Config for a repository.

Parameters:

Name Type Description Default
root Path | None

Repository root (where .grip.toml / pyproject.toml live), or None.

required
env dict[str, str] | None

Environment mapping. Defaults to :data:os.environ.

None
overrides dict[str, Any] | None

Values from command-line flags. None entries are ignored.

None
Source code in src/grip_hook/config.py
def load_config(
    root: Path | None,
    *,
    env: dict[str, str] | None = None,
    overrides: dict[str, Any] | None = None,
) -> Config:
    """Build the effective :class:`Config` for a repository.

    Args:
        root: Repository root (where ``.grip.toml`` / ``pyproject.toml`` live), or ``None``.
        env: Environment mapping. Defaults to :data:`os.environ`.
        overrides: Values from command-line flags. ``None`` entries are ignored.
    """
    return resolve_config(root, env=env, overrides=overrides).config

render_template

render_template(*, provider: str, passing_score: int = DEFAULT_PASSING_SCORE, difficulty: str = 'normal') -> str

The commented .grip.toml that grip init writes.

Source code in src/grip_hook/config.py
def render_template(
    *, provider: str, passing_score: int = DEFAULT_PASSING_SCORE, difficulty: str = "normal"
) -> str:
    """The commented ``.grip.toml`` that ``grip init`` writes."""
    cfg = _apply(
        Config(),
        {"provider": provider, "passing_score": passing_score, "difficulty": difficulty},
        "grip init",
    ).validate()
    return TEMPLATE.format(
        passing_score=cfg.passing_score, difficulty=cfg.difficulty.value, provider=cfg.provider
    )

describe

describe(cfg: Config) -> list[tuple[str, str]]

Return (name, value) pairs for display.

Source code in src/grip_hook/config.py
def describe(cfg: Config) -> list[tuple[str, str]]:
    """Return ``(name, value)`` pairs for display."""
    rows: list[tuple[str, str]] = []
    for f in fields(cfg):
        value = getattr(cfg, f.name)
        if f.name == "paths":
            text = "; ".join(rule.describe() for rule in cfg.paths) or "(none)"
        elif isinstance(value, tuple):
            text = ", ".join(value) if value else "(none)"
        elif isinstance(value, Difficulty):
            text = value.value
        else:
            text = str(value) if value != "" else "(provider default)"
        if f.name == "provider" and value == AUTO_PROVIDER:
            text = f"{AUTO_PROVIDER} ({cfg.provider_name})"
        rows.append((f.name, text))
    return rows