While building an AI agent with Git capabilities, I found myself repeating the same logic: checking whether the agent’s working directory was a Git repository before attempting any Git operation. This check appeared inside every Git-related tool that I made available to the agent.
For example, if I wanted to commit a Git message I would have written something like this:
def commit_git_message(working_directory, directory=None, message):
"""
This function checks if the given directory is a Git repo and executes a git commit operation
"""
# get the absolute path of the given directory
abs_working_dir = Path(working_directory).resolve()
if directory:
target_dir = abs_working_dir / directory
else:
target_dir = abs_working_dir
# check if target directory is inside the working directory
if not target_dir.is_relative_to(abs_working_dir):
return f'Error: Cannot work on "{target_dir}" as it is outside the permitted working directory'
# check if the working directory is indeed a directory
if not target_dir.is_dir():
return f'Error: "{target_dir}" is not a directory'
try:
# the following command returns "true" if the directory is a Git repo or inside a Git repo
check_git_dir = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=str(target_dir),
capture_output=True,
check=True,
)
if check_git_dir.returncode != 0:
return f'Error: "{target_dir}" is not a Git directory'
except subprocess.CalledProcessError as e:
return str(e)
### test of the logic
Now imagine this logic repeated inside every Git tool like some boilerplate code. Not very DRY.
Hopefully the example above makes sense, and you can already see why wrapping the logic in a helper function is a better design choice. It’s cleaner and the code becomes far more maintainable.
So I thought of how I could do this.
Now, the descriptive messages returned in the code above, whether success or failure, matter because they serve as guardrails for the agent when carrying out tasks. AI agents rely heavily on feedback messages to understand the situation and adjust their next actions accordingly. Even more importantly, those messages are crucial for safety and control. They prevent the agent from making assumptions and continuing down a wrong path.
With this in mind, I knew I didn’t want to return just None in cases where the working directory wasn’t a Git repo or some other thing happened.
Go’s approach: the comma-ok idiom
In Go, I would just use the comma-ok pattern. This means returning the message and a boolean. It helps that Go natively supports multiple return values. It’s also simpler and less verbose to handle compared to returning a string and an error type. Something like this:
func checkGitCommand(directory string) error {
cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree")
cmd.Dir = directory
err := cmd.Run()
if err != nil {
return fmt.Errorf("%q is not a git directory: %w", directory, err)
}
return nil
}
func checkGitRepo(directory string) (string, bool) {
absWorkingDir, err := filepath.Abs(directory)
if err != nil {
return fmt.Sprintf("error: failed to resolve absolute path: %v", err), false
}
targetDir := absWorkingDir
info, err := os.Stat(targetDir)
if err != nil {
if os.IsNotExist(err) {
return fmt.Sprintf("directory %q does not exist", targetDir), false
}
return fmt.Sprintf("failed to check directory %q: %v", targetDir, err), false
}
if !info.IsDir() {
return fmt.Sprintf("Error: %q is not a directory", targetDir), false
}
if err := checkGitCommand(targetDir); err != nil {
return fmt.Sprintf("Error: %q is not a Git repository", targetDir), false
}
return fmt.Sprintf("Success: %q is a valid Git repository", targetDir), true
}
You would then call like so:
func main() {
// some logic...
directory := "/path/to/directory"
message, ok := checkGitRepo(directory)
if !ok {
fmt.Println(message)
os.Exit(1)
}
// do something else
Python Approaches Compared
With Python, I had one of three options:
- I could mirror the Go approach and return a tuple containing a string and a boolean
- Use a dataclass for more structure
- Raise and handle exceptions
Let’s take it one after the other.
1. Returning a tuple
Python does not natively support multiple return values like Go, but you can return a tuple containing your desired values and unpack them at the point of usage. It’s simple enough and shares the same syntax with the Go approach. Although one could argue it has some negligible overhead compared to Go.
def check_git_repo(directory: str) -> tuple[str, bool]:
abs_working_dir = Path(directory).resolve()
target_dir = abs_working_dir
if not target_dir.is_dir():
return f'Error: "{target_dir}" is not a directory', False
try:
check_git_dir = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=str(target_dir),
capture_output=True,
check=True,
)
if check_git_dir.returncode != 0:
return f'Error: "{target_dir}" is not a git directory', False
except subprocess.CalledProcessError as e:
return f'Error: {str(e)}', False
return f'Success: "{target_dir}" is a Git directory', True
At the point of usage, you just do:
message, is_git = check_git_repo("/path") # Tuple unpacking
if is_git:
# do something...
In our case, that will be:
def commit_git_message(working_directory, directory=None, message):
"""
This function checks if the given directory is a Git repo and executes a git commit operation
"""
# get the absolute path of the given directory
abs_working_dir = Path(working_directory).resolve()
if directory:
target_dir = abs_working_dir / directory
else:
target_dir = abs_working_dir
# check if target directory is inside the working directory
if not target_dir.is_relative_to(abs_working_dir):
return f'Error: Cannot work on "{target_dir}" as it is outside the permitted working directory'
# Use the helper
message, is_git = check_git_repo(str(target_dir))
if not is_git:
return message
try:
result = subprocess.run(
["git", "commit", "-m", message],
cwd=str(target_dir),
capture_output=True,
)
if result.returncode != 0:
return f'Error: {result.stderr.decode("utf-8")}'
except subprocess.CalledProcessError as e:
print(str(e))
return f'Error: {e.stderr.decode("utf-8")}'
return f'Commit successful: "{message}"'
Simple unpacking. Works well for a simple use case like mine.
2. Returning a dataclass
This is the more verbose but structured approach. Useful if you want to include some additional metadata for more complex applications.
@dataclass
class GitRepoData:
success: bool
message: str
path: str
error_code: str | None = None
@property
def is_ok(self) -> bool:
return self.success
def check_git_repo(directory) -> GitRepoData:
abs_working_dir = Path(directory).resolve()
target_dir = abs_working_dir
if not target_dir.is_dir():
return GitRepoData(
success=False,
message=f'"{target_dir}" is not a directory',
path=str(target_dir),
)
try:
check_git_dir = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=str(target_dir),
capture_output=True,
check=True,
)
if check_git_dir.returncode != 0:
return GitRepoData(
success=False,
message=f'"{target_dir}" is not a git directory',
path=str(target_dir),
)
except subprocess.CalledProcessError as e:
return GitRepoData(
success=False,
message=str(e),
path=str(target_dir),
)
return GitRepoData(
success=True, message=f'"{target_dir}" is a Git directory', path=str(target_dir)
)
You would then use this as follows:
result = check_git_repo(directory)
if result.is_ok():
# do something
The idea of using the dot operator makes this approach slightly more readable but the implementation is a bit more verbose.
3. Exceptions approach
I could also raise different specific exceptions for different errors and then do some error handling when I want to call the helper function. This, I would argue, seems like the most “pythonic” way to do this.
class GitRepoError(Exception):
def __init__(self, message: str, path: str, error_code: str | None = None):
self.message = message
self.path = path
self.error_code = error_code
super().__init__(message)
class NotDirectoryError(GitRepoError):
pass
class NotGitRepoError(GitRepoError):
pass
class GitCommandError(GitRepoError):
pass
def check_git_repo(directory) -> str:
abs_working_dir = Path(directory).resolve()
target_dir = abs_working_dir
if not target_dir.is_dir():
raise NotDirectoryError(
f'"{target_dir}" is not a directory', str(target_dir), "NOT_DIRECTORY"
)
try:
check_git_dir = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=str(target_dir),
capture_output=True,
check=True,
)
if check_git_dir.returncode != 0:
raise NotGitRepoError(
f'"{target_dir}" is not a Git directory',
str(target_dir),
"NOT_GIT_REPO",
)
except subprocess.CalledProcessError as e:
raise GitCommandError(str(e), str(target_dir), "GIT_COMMAND_FAILED")
return f'"{target_dir}" is a git directory'
You would then use this as follows:
#...
try:
message = check_git_repo(directory)
# do some git operations
except NotDirectoryError:
# Maybe create directory?
except NotGitRepoError:
# Maybe initialize git repo?
except GitCommandError:
# Handle git command failure
except GitRepoError:
# Handle other errors
The last approach would be a no-brainer if I was writing a library for other developers. It would make sense because of the specific error handling. But that’s not what I am doing. I only want to check if a directory is a Git repo but with an accompanying status message.
As you may have guessed, I went with the tuple approach as it was the simplest and made the most sense for my use case.
Reminds me of the KISS principle
KISS is an acronym for “Keep It Simple, Stupid.” It is a design philosophy that suggests that complex code should be avoided in favor of simple, well-documented code.
In the past, I would most likely have gone for the most sophisticated implementation, even when it offered no concrete benefit. But growing as a software engineer means understanding that the best code isn’t always the most complex-looking. Rather, it’s the solution that solves your problem clearly and simply.
I now know that every line of code is a potential liability. In my use case, that meant using the tuple approach. For you, it may mean taking the exceptions route.