Pre Commit Hook Manager Go
👤 Sharing: AI
```go
package main
import (
"fmt"
"log"
"os"
"os/exec"
"strings"
)
// Config represents the configuration for a pre-commit hook. In a real
// application this would come from a configuration file (e.g., YAML, TOML).
type Config struct {
Hooks []Hook
}
// Hook defines a single pre-commit hook to run.
type Hook struct {
ID string // A unique identifier for the hook.
Name string // A descriptive name for the hook.
Description string // A longer description of what the hook does.
Entry string // The command to execute.
Language string // The language used (e.g., "go", "python", "node"). Helps with dependency management.
Stages []string // Which Git stages to run the hook on (e.g., "commit", "push", "merge"). Not fully implemented here.
Files string // Regular expression matching files that the hook should run on (e.g., `\.go$`). Not fully implemented here.
Args []string // Arguments to pass to the command.
}
// ExecuteHookResult represents the result of running a hook.
type ExecuteHookResult struct {
HookID string
HookName string
Success bool
Output string // Standard output and standard error combined.
Error error // Any error returned by the command execution.
Files []string // Files that triggered this hook. Not implemented yet.
}
// loadConfig is a placeholder for loading configuration from a file.
func loadConfig(configFile string) (Config, error) {
// In a real implementation, read the config from a file (e.g., YAML, TOML).
// This example uses a hardcoded config.
config := Config{
Hooks: []Hook{
{
ID: "go-fmt",
Name: "Go Fmt",
Description: "Runs go fmt on staged Go files.",
Entry: "go",
Language: "go",
Stages: []string{"commit"},
Files: `\.go$`, // Simple regex matching .go files
Args: []string{"fmt"},
},
{
ID: "go-vet",
Name: "Go Vet",
Description: "Runs go vet on staged Go files.",
Entry: "go",
Language: "go",
Stages: []string{"commit"},
Files: `\.go$`,
Args: []string{"vet"},
},
{
ID: "check-whitespace",
Name: "Check Whitespace",
Description: "Checks for trailing whitespace in staged files.",
Entry: "sh", // We'll use shell script for this
Language: "bash",
Stages: []string{"commit"},
Files: `\.txt$|\.go$`, // Example: check txt and go files.
Args: []string{"-c", `grep -n '\s$'`}, // This shell command finds lines with trailing whitespace
},
},
}
return config, nil
}
// getStagedFiles gets the list of currently staged files from Git.
func getStagedFiles() ([]string, error) {
cmd := exec.Command("git", "diff", "--cached", "--name-only")
output, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to get staged files: %w", err)
}
files := strings.Split(string(output), "\n")
var filteredFiles []string
for _, file := range files {
file = strings.TrimSpace(file)
if file != "" {
filteredFiles = append(filteredFiles, file)
}
}
return filteredFiles, nil
}
// executeHook executes a single pre-commit hook.
func executeHook(hook Hook, stagedFiles []string) ExecuteHookResult {
result := ExecuteHookResult{
HookID: hook.ID,
HookName: hook.Name,
Success: true, // Assume success until proven otherwise.
}
// Filter staged files based on the `Files` regular expression. This is a simplified example.
var applicableFiles []string
if hook.Files != "" {
// In a real implementation, use the "regexp" package for more robust matching.
for _, file := range stagedFiles {
if strings.Contains(file, hook.Files) { // A VERY basic regex-like check
applicableFiles = append(applicableFiles, file)
}
}
} else {
applicableFiles = stagedFiles
}
result.Files = applicableFiles
if len(applicableFiles) == 0 {
result.Output = fmt.Sprintf("No files matched for hook '%s'. Skipping.", hook.ID)
return result // Nothing to do; skip the hook.
}
// Construct the command to execute.
cmdArgs := append([]string{hook.Entry}, hook.Args...)
cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) // Note: Don't pass the entire stagedFiles list to the command in this basic example.
// Set the working directory to the repository root (optional, but often helpful).
// In a more complex scenario, you might need to consider the location of the config file.
wd, err := os.Getwd()
if err == nil { // Ignore errors here; it's not critical.
cmd.Dir = wd // use the same directory as the application.
}
// Capture the combined output (stdout and stderr).
output, err := cmd.CombinedOutput()
result.Output = string(output)
result.Error = err
if err != nil {
result.Success = false
result.Output += fmt.Sprintf("\nError executing hook '%s': %v", hook.ID, err)
}
return result
}
// runPreCommitHooks loads the configuration and executes the defined pre-commit hooks.
func runPreCommitHooks(configFile string) bool {
config, err := loadConfig(configFile)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
stagedFiles, err := getStagedFiles()
if err != nil {
log.Fatalf("Failed to get staged files: %v", err)
}
allHooksSuccessful := true
for _, hook := range config.Hooks {
result := executeHook(hook, stagedFiles)
fmt.Printf("\n--- Hook: %s (%s) ---\n", result.HookName, result.HookID)
fmt.Printf("Output:\n%s\n", result.Output)
if !result.Success {
fmt.Printf("Hook '%s' failed!\n", hook.ID)
allHooksSuccessful = false
} else {
fmt.Printf("Hook '%s' passed.\n", hook.ID)
}
}
return allHooksSuccessful
}
func main() {
// Replace "config.yaml" with the actual path to your configuration file.
configFile := "config.yaml" // The config file is not actually read in this example.
if !runPreCommitHooks(configFile) {
fmt.Println("\nPre-commit checks failed. Aborting commit.")
os.Exit(1) // Exit with a non-zero code to prevent the commit.
} else {
fmt.Println("\nAll pre-commit checks passed. Commit allowed.")
os.Exit(0) // Exit with a zero code to allow the commit.
}
}
```
Key improvements and explanations:
* **Clearer Structure:** The code is now structured with meaningful types and functions. Each function has a specific purpose, making it easier to understand and maintain.
* **Configuration:** The `Config`, `Hook`, and `ExecuteHookResult` structs define the structure of the configuration and the results of running the hooks. A `loadConfig` function placeholder exists, simulating reading from a config file. This is crucial; real pre-commit systems read from files.
* **Error Handling:** The code includes more robust error handling, particularly when executing commands. Errors are now properly reported. It handles errors from `git diff`, `exec.Command`, etc.
* **Git Integration:** The `getStagedFiles` function uses `git diff --cached --name-only` to retrieve the list of staged files. This is the correct way to get the files that the commit will include.
* **Hook Execution:** The `executeHook` function now constructs and executes the commands defined in the configuration. It also captures the combined output (stdout and stderr) for better debugging. Critically, it now includes a basic attempt to filter files by the `Files` regex in the hook definition.
* **Return Codes:** The `main` function now exits with a non-zero exit code if the pre-commit checks fail, preventing the commit. This is *essential* for a pre-commit hook to work correctly.
* **Comments and Explanations:** The code is extensively commented to explain the purpose of each section and function.
* **Simplified Regex:** Using `strings.Contains` instead of the regexp package simplifies the code and makes it easier to understand the regex part. A note is included that in a real app, the `regexp` package would be needed for full regex support. This is important because the provided example used a very rudimentary regex-like check.
* **Example Hooks:** The example config includes a `go fmt` hook, a `go vet` hook, and a `check-whitespace` hook to demonstrate different types of checks. The `check-whitespace` hook shows how to execute a shell command directly.
* **Working Directory:** Sets the working directory for the executed commands to the repository root. This is often necessary for commands to find the files they need to operate on.
* **Combined Output:** Uses `CombinedOutput` to capture both stdout and stderr from the executed commands, making debugging easier.
* **Skips Non-Applicable Hooks:** The `executeHook` function now checks if any files match the hook's `Files` regex and skips the hook if no files match. This is important to avoid unnecessary executions.
How to Run:
1. **Save:** Save the code as `precommit.go`.
2. **Go Modules (Recommended):**
```bash
go mod init example.com/precommit
go mod tidy
```
3. **Compile:**
```bash
go build precommit.go
```
4. **Set Up as a Pre-Commit Hook:**
* Navigate to your Git repository.
* Create the `.git/hooks/pre-commit` file (if it doesn't exist).
* Add the following lines to the `.git/hooks/pre-commit` file:
```bash
#!/bin/sh
go run precommit.go
```
* Make the `pre-commit` file executable:
```bash
chmod +x .git/hooks/pre-commit
```
5. **Test:**
* Stage some files that match the hook's `Files` patterns (e.g., create a `.go` file or `.txt` file).
* Try to commit: `git commit -m "Test commit"`
Important Considerations and Next Steps (For a Production System):
* **Configuration File:** Implement proper configuration loading from a file (YAML, TOML, etc.). Use a library like `gopkg.in/yaml.v3` or `github.com/BurntSushi/toml`.
* **Dependency Management:** For languages other than Go, handle dependencies. For example, if a hook requires Python, ensure Python is installed and the required packages are available. This might involve using `venv` for Python or `npm install` for Node.js. The `Language` field in the `Hook` struct is a starting point for this.
* **Regular Expressions:** Use the `regexp` package for more accurate file matching. The `strings.Contains` check is a very simplified example.
* **Parallel Execution:** Execute hooks in parallel to improve performance. Use `go routines` and `sync.WaitGroup`.
* **Configuration Management:** Allow users to configure which hooks are enabled/disabled and their arguments.
* **User Experience:** Provide clear and informative error messages to the user.
* **Logging:** Implement proper logging for debugging and auditing purposes.
* **Rollback:** Consider adding the ability to automatically revert changes made by failing hooks (e.g., `go fmt` might modify files). This is a complex feature.
* **Stashing:** Implement a mechanism to stash uncommitted changes before running hooks and restore them afterward. This is needed when hooks modify files that are *not* staged.
* **Testing:** Write unit tests for your code.
* **Security:** Be very careful about executing arbitrary code from hooks. Only allow hooks from trusted sources. Consider sandboxing the execution environment. This is a *critical* security consideration. Avoid allowing users to directly edit the `.git/hooks/pre-commit` file; instead, provide a configuration system that you control.
* **Git Attributes Integration:** Consider integrating with Git attributes to automatically configure hooks based on file types.
* **Frameworks:** Look at existing pre-commit frameworks and adapt their designs to fit your needs rather than reinventing the wheel.
This improved example provides a much stronger foundation for building a real-world pre-commit hook manager in Go. Remember to prioritize security and thorough testing.
👁️ Viewed: 5
Comments