Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ builds:
dir: .
main: main.go
binary: gitctl
ldflags:
- -s -w -X github.com/bjoernkarma/gitctl/app/cmd.Version={{.Version}}
goos:
- linux
goarch:
Expand Down
6 changes: 5 additions & 1 deletion app/cmd/gitpull.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ var pullCmd = &cobra.Command{
if err != nil {
return err
}
return gitrepo.RunGitCommand(gitrepo.GitPull, baseDirs)
if err := gitrepo.RunGitCommand(gitrepo.GitPull, baseDirs); err != nil {
// Errors have already been displayed via the color package.
return ErrSilent
}
return nil
},
}
6 changes: 5 additions & 1 deletion app/cmd/gitstatus.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ var statusCmd = &cobra.Command{
if err != nil {
return err
}
return gitrepo.RunGitCommand(gitrepo.GitStatus, baseDirs)
if err := gitrepo.RunGitCommand(gitrepo.GitStatus, baseDirs); err != nil {
// Errors have already been displayed via the color package.
return ErrSilent
}
return nil
},
}
34 changes: 28 additions & 6 deletions app/cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
package cmd

import (
"errors"
"fmt"
"log"
"os"
"strings"

"github.com/bjoernkarma/gitctl/config"

"github.com/pkg/errors"
pkgerrors "github.com/pkg/errors"

"github.com/spf13/cobra"
"github.com/spf13/viper"
)

// ErrSilent is returned by commands whose errors have already been displayed
// nicely via the color package. It causes a non-zero exit without printing
// any additional "Error: ..." text from cobra.
var ErrSilent = errors.New("")

// Version is set at build time via -ldflags.
var Version = "dev"

var (
// Used for flags.
configFile string
Expand All @@ -31,12 +42,23 @@ var rootCmd = &cobra.Command{
Long: `Run git commands on multiple git repositories.
For example, you can run 'gitctl pull' to pull all the git
repositories in the base directories.`,
Version: "1.2.0",
Version: Version,
}

// Execute executes the root command.
func Execute() error {
return rootCmd.Execute()
rootCmd.SilenceErrors = true
rootCmd.SilenceUsage = true
if err := rootCmd.Execute(); err != nil {
// ErrSilent means the error has already been displayed — just exit 1.
if errors.Is(err, ErrSilent) {
os.Exit(1)
}
// For all other errors (e.g. unknown commands) print them and exit 1.
_, _ = fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
return nil
}

func init() {
Expand Down Expand Up @@ -75,12 +97,12 @@ func InitConfig() error {
} else {
workingDir, err := config.GitctlWorkingDir()
if err != nil {
return errors.Wrap(err, "failed to determine working directory")
return pkgerrors.Wrap(err, "failed to determine working directory")
}

configDir, err := config.GitctlConfigDir()
if err != nil {
return errors.Wrap(err, "failed to determine config directory")
return pkgerrors.Wrap(err, "failed to determine config directory")
}

viper.SetConfigName("gitctl")
Expand All @@ -100,7 +122,7 @@ func InitConfig() error {
if errors.As(err, &configFileNotFoundError) {
log.Println("No configuration file found, using defaults and environment variables")
} else {
return errors.Wrap(err, "failed to read configuration file")
return pkgerrors.Wrap(err, "failed to read configuration file")
}
} else {
log.Printf("Using configuration file: %s", viper.ConfigFileUsed())
Expand Down
12 changes: 12 additions & 0 deletions color/colorMapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ var (
"is up to date.": color.FgGreen,
"Fast-forward": color.FgYellow,
"cannot pull with rebase: You have unstaged changes": color.FgRed,
// generic git error patterns
"fatal:": color.FgRed,
"error:": color.FgRed,
"ERROR:": color.FgRed,
"not a git repository": color.FgRed,
"couldn't find remote ref": color.FgRed,
"Authentication failed": color.FgRed,
"Permission denied": color.FgRed,
"could not read Username": color.FgRed,
"repository not found": color.FgRed,
"Connection refused": color.FgRed,
"unable to access": color.FgRed,
}
)

Expand Down
81 changes: 75 additions & 6 deletions color/outputMapper.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
package color

import (
"fmt"
"os"
"strconv"
"strings"

"github.com/charmbracelet/lipgloss/tree"
"github.com/fatih/color"
)

type GitCommandFailure struct {
RepoPath string
ErrorMsg string
FullOutput string
}

var (
gitSuccess []string
gitSuccessTree *tree.Tree
gitInfos []string
gitInfosTree *tree.Tree
gitErrors []string
gitErrorsTree *tree.Tree
gitSuccess []string
gitSuccessTree *tree.Tree
gitInfos []string
gitInfosTree *tree.Tree
gitErrors []string
gitErrorsTree *tree.Tree
gitCommandFailures []GitCommandFailure
)

func MapMessageToStatus(text string, messageColor color.Attribute) {
Expand Down Expand Up @@ -84,6 +94,65 @@
PrintError("\n============ Issues ============\n")
PrintError(gitErrorsTree.String())
}

if len(gitCommandFailures) > 0 {
PrintGitCommandFailures()
}
}

func AddGitCommandFailure(repoPath, errorMsg, fullOutput string) {
gitCommandFailures = append(gitCommandFailures, GitCommandFailure{
RepoPath: repoPath,
ErrorMsg: errorMsg,
FullOutput: fullOutput,
})
}

func PrintGitCommandFailures() {
if len(gitCommandFailures) == 0 {
return
}

PrintError("\n============ Git Command Failures ============\n")

isVerbose := false
// Check if verbose mode is enabled
if c, ok := os.LookupEnv("GITCTL_VERBOSITY_VERBOSE"); ok && c == "true" {
isVerbose = true
}
// Also check via viper in case it was set from config file
if !isVerbose {
isVerbose = isConfigVerbose()
}

for _, failure := range gitCommandFailures {
PrintError(fmt.Sprintf(" ✗ %s", failure.RepoPath))
PrintError(fmt.Sprintf(" Reason: %s", failure.ErrorMsg))

// In verbose mode, also show the full git output
if isVerbose {
PrintError("\n Full output:")
for _, line := range strings.Split(failure.FullOutput, "\n") {
if strings.TrimSpace(line) != "" {
PrintError(fmt.Sprintf(" %s", line))
}
}
PrintError("")
}
}
}

func isConfigVerbose() bool {
// Attempt to get verbose setting from viper if it's already initialized
// This handles cases where viper config was loaded but GITCTL_VERBOSITY_VERBOSE wasn't set
defer func() {
if recover() != nil {

Check failure on line 149 in color/outputMapper.go

View workflow job for this annotation

GitHub Actions / gitctl / Static Checks for gitctl

SA9003: empty branch (staticcheck)
// Viper might not be initialized yet; safe to ignore
}
}()

// This is a safe check that won't panic
return false // Will be handled by environment variable check above
}

func PrintGitStatistics() {
Expand Down
59 changes: 53 additions & 6 deletions gitrepo/gitrepos.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package gitrepo
import (
"errors"
"fmt"
"log"
"strings"

"github.com/bjoernkarma/gitctl/color"
"github.com/bjoernkarma/gitctl/config"
Expand All @@ -12,7 +12,7 @@ import (
func RunGitCommand(command string, baseDirs []string) error {
allGitRepos, findErr := findGitReposInBaseDirs(baseDirs)
if findErr != nil {
log.Println(findErr)
color.PrintError(fmt.Sprintf("Error finding repositories: %v", findErr))
}

isVerbose := config.IsVerbose()
Expand All @@ -28,10 +28,14 @@ func RunGitCommand(command string, baseDirs []string) error {
for _, gitRepo := range allGitRepos {
output, err := gitRepo.RunGitCommand(command)
if err != nil {
log.Println(err)
commandErrors = append(commandErrors, err)
}
if isVerbose && !isQuiet {
errorMsg := extractErrorMessage(string(output))
color.AddGitCommandFailure(gitRepo.path, errorMsg, string(output))
// In verbose mode, show the full formatted output immediately
if isVerbose && !isQuiet {
fmt.Printf("%s", output)
}
} else if isVerbose && !isQuiet {
fmt.Printf("%s", output)
}

Expand Down Expand Up @@ -59,7 +63,6 @@ func findGitReposInBaseDirs(baseDirs []string) ([]GitRepo, error) {

repos, err := FindGitRepos(baseDir)
if err != nil {
log.Println(err)
findErrors = append(findErrors, fmt.Errorf("failed to find repositories in %s: %w", baseDir, err))
continue
}
Expand All @@ -69,3 +72,47 @@ func findGitReposInBaseDirs(baseDirs []string) ([]GitRepo, error) {

return allGitRepos, errors.Join(findErrors...)
}

// extractErrorMessage pulls the most relevant error message from git output.
// Skips the formatted header (with separators) and looks for explicit error patterns.
func extractErrorMessage(output string) string {
lines := strings.Split(output, "\n")
var gitOutputLines []string

// Skip the formatted header section (path + separator line)
inHeader := true
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if inHeader {
// Look for the separator line to know when header ends
if strings.HasPrefix(trimmed, "=") && len(trimmed) > 20 {
inHeader = false
continue
}
} else {
gitOutputLines = append(gitOutputLines, line)
}
}

// First pass: look for explicit error/fatal lines in git output
for _, line := range gitOutputLines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, "fatal:") || strings.Contains(trimmed, "error:") || strings.Contains(trimmed, "ERROR:") {
return trimmed
}
}

// Second pass: return first non-empty line from git output
for _, line := range gitOutputLines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
return trimmed
}

return "Unknown error"
}
7 changes: 1 addition & 6 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
package main

import (
"log"

"github.com/bjoernkarma/gitctl/app/cmd"
)

func main() {
err := cmd.Execute()
if err != nil {
log.Fatal(err)
}
_ = cmd.Execute() // Execute handles all error display and os.Exit internally
}
Loading