vet

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 2dff91a3d23d3f38ad4edac52604028152436007
parent a232e9ffa88dd984eef36c139ca06267b162167a
Author: Yash Dive <70193427+yashdive@users.noreply.github.com>
Date:   Mon, 16 Feb 2026 18:40:11 -0500

feat: structured GitCommandError for user-friendly git failures (#89)


Diffstat:
Mvet/errors.py | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mvet/repo_utils.py | 15++++++++++++---
2 files changed, 94 insertions(+), 3 deletions(-)

diff --git a/vet/errors.py b/vet/errors.py @@ -1,4 +1,5 @@ import subprocess +from pathlib import Path from typing import Any @@ -17,3 +18,84 @@ class RunCommandError(subprocess.CalledProcessError): def __str__(self) -> str: return f"Command `{self.cmd}` returned non-zero exit status {self.returncode}.\nOutput: {self.stdout}\nError: {self.stderr}\nCWD: {self.cwd}" + + +class GitCommandError(GitException): + """Structured git error handler for consistent error reporting. + + Translates RunCommandError into user-friendly messages with context + about what operation failed and why. + """ + + def __init__(self, error: RunCommandError, operation: str, repo_path: Path): + """Initialize with error context. + + Args: + error: The underlying RunCommandError + operation: Human-readable description of what was being attempted + repo_path: Path to the repository + """ + self.error = error + self.operation = operation + self.repo_path = repo_path + super().__init__(self.user_message()) + + def user_message(self) -> str: + """Generate a user-friendly error message with full context.""" + stderr = self.error.stderr or "" + + # Build the message with context + lines = [ + f"Git operation failed: {self.operation}", + f"Repository: {self.repo_path}", + f"Command: {self.error.cmd}", + "", + ] + + # Extract the core error message + if stderr.strip(): + # Get just the error line, not the full traceback + error_lines = stderr.strip().split("\n") + error_msg = error_lines[-1] # Usually the most relevant line is last + lines.append(f"Error: {error_msg}") + else: + lines.append(f"Exit code: {self.error.returncode}") + + # Add helpful troubleshooting hints based on the error + lines.append("") + lines.extend(self._get_troubleshooting_hints(stderr)) + + return "\n".join(lines) + + def _get_troubleshooting_hints(self, stderr: str) -> list[str]: + """Generate troubleshooting hints based on the error message.""" + hints = [] + + # Common git errors + if "not a git repository" in stderr: + hints.append("Troubleshooting:") + hints.append(" • Ensure the repository path points to a valid git repository") + hints.append(" • Check that .git directory exists in the repository") + + elif "no such ref" in stderr.lower() or "does not point to a valid object" in stderr.lower(): + hints.append("Troubleshooting:") + hints.append(" • The repository may have no commits yet") + hints.append(" • Try making an initial commit before running vet") + + elif "bad revision" in stderr.lower() or "unknown revision" in stderr.lower(): + hints.append("Troubleshooting:") + hints.append(" • The specified git ref/branch may not exist") + hints.append(" • Verify the branch or commit hash is correct") + + elif "permission denied" in stderr.lower(): + hints.append("Troubleshooting:") + hints.append(" • Check file permissions on the repository") + hints.append(" • Ensure you have read/write access to the .git directory") + + else: + # Generic fallback hints + hints.append("Troubleshooting:") + hints.append(" • Check your git configuration and repository state") + hints.append(" • Run 'git status' to diagnose repository issues") + + return hints diff --git a/vet/repo_utils.py b/vet/repo_utils.py @@ -1,5 +1,6 @@ from pathlib import Path +from vet.errors import GitCommandError from vet.errors import GitException from vet.errors import RunCommandError from vet.git import SyncLocalGitRepo @@ -22,7 +23,11 @@ def get_code_to_check(relative_to: str, repo_path: Path) -> tuple[str, str, str] try: base_commit = find_relative_to_commit_hash(relative_to, repo_path=repo_path) except RunCommandError as e: - raise GitException(f"Unable to determine base commit for code verification: {e}") from e + raise GitCommandError( + e, + "determine base commit for verification", + repo_path, + ) from e repo = SyncLocalGitRepo(repo_path) @@ -31,13 +36,17 @@ def get_code_to_check(relative_to: str, repo_path: Path) -> tuple[str, str, str] combined_diff = repo.get_git_diff(commit_hash=base_commit) combined_diff_no_binary = repo.get_git_diff(commit_hash=base_commit, include_binary=False) except RunCommandError as e: - raise GitException(f"Unable to get diff to {base_commit}: {e}") from e + raise GitCommandError( + e, + f"get diff since commit {base_commit}", + repo_path, + ) from e # Get untracked files since we want to include these as part of the unstaged and full changes try: untracked_files = repo.get_untracked_files() except RunCommandError as e: - raise GitException(f"Unable to get untracked files: {e}") from e + raise GitCommandError(e, "list untracked files", repo_path) from e # Create diffs for untracked files (treat them as new files) untracked_diffs = []