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:
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 = []