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
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ To install `gitctl`, follow these steps:

## Configuration

Add a `gitctl.yaml`file the `.config\gitctl` folder in your home directory (`~/.config\gitctl\gitctl.yaml`) with the following format:
`gitctl` searches for `gitctl.yaml` in this order:

1. Current working directory
2. `~/.config/gitctl/`

You can also pass an explicit file with `--config /path/to/gitctl.yaml`.

Create `~/.config/gitctl/gitctl.yaml` with the following format:

```yaml
# Verbosity settings
Expand All @@ -59,6 +66,26 @@ base_dirs:
- "//dev//gitctl"
```

### Environment Variables

Environment variables use the `GITCTL_` prefix. Dots in config keys are mapped to underscores.

Examples:

- `verbosity.verbose` -> `GITCTL_VERBOSITY_VERBOSE`
- `run_mode.local` -> `GITCTL_RUN_MODE_LOCAL`
- `run_mode.dry_run` -> `GITCTL_RUN_MODE_DRY_RUN`
- `output.color` -> `GITCTL_OUTPUT_COLOR`

### Precedence

Configuration values are resolved in this order (highest to lowest):

1. CLI flags
2. Environment variables
3. Config file
4. Built-in defaults

## Usage

Here's how you can use `gitctl`:
Expand Down Expand Up @@ -88,11 +115,17 @@ Available Commands:
status Execute git status on multiple git repositories.

Flags:
--config string config file (default is $HOME/gitctl.yaml)
--config string config file (default search: ./gitctl.yaml, then ~/.config/gitctl/gitctl.yaml)
-h, --help help for gitctl
-q, --quiet suppress output
-v, --verbose verbose output
-d, --debug debug output
-l, --local run with working directory used as base directory
-D, --dryRun run with dry run mode
-c, --color color output (default true)
-C, --concurrency number of concurrent operations (default "1")
--base.dirs base directories for git repositories
--version version for gitctl
--viper use Viper for configuration (default true)

Use "gitctl [command] --help" for more information about a command.
```
Expand Down
8 changes: 6 additions & 2 deletions app/cmd/gitpull.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import (
var pullCmd = &cobra.Command{
Use: "pull",
Short: "Execute git pull on multiple git repositories.",
Run: func(cmd *cobra.Command, args []string) {
gitrepo.RunGitCommand(gitrepo.GitPull, config.GetBaseDirs())
RunE: func(cmd *cobra.Command, args []string) error {
baseDirs, err := config.GetBaseDirs()
if err != nil {
return err
}
return gitrepo.RunGitCommand(gitrepo.GitPull, baseDirs)
},
}
4 changes: 3 additions & 1 deletion app/cmd/gitpull_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import (

func TestPullCommandExecutesGitPullOnLocalRepo(t *testing.T) {
var buf bytes.Buffer
t.Setenv("GITCTL_VERBOSITY_DEBUG", "true")
originalLogWriter := log.Writer()
log.SetOutput(&buf)
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
defer func() {
log.SetOutput(nil)
log.SetOutput(originalLogWriter)
}()

rootCmd.SetArgs([]string{"pull", "--local", "--debug", "--verbose"})
Expand Down
8 changes: 6 additions & 2 deletions app/cmd/gitstatus.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import (
var statusCmd = &cobra.Command{
Use: "status",
Short: "Execute git status on multiple git repositories.",
Run: func(cmd *cobra.Command, args []string) {
gitrepo.RunGitCommand(gitrepo.GitStatus, config.GetBaseDirs())
RunE: func(cmd *cobra.Command, args []string) error {
baseDirs, err := config.GetBaseDirs()
if err != nil {
return err
}
return gitrepo.RunGitCommand(gitrepo.GitStatus, baseDirs)
},
}
6 changes: 4 additions & 2 deletions app/cmd/gitstatus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ import (

func TestStatusCommandExecutesGitStatusOnLocalRepo(t *testing.T) {
var buf bytes.Buffer
t.Setenv("GITCTL_VERBOSITY_DEBUG", "true")
originalLogWriter := log.Writer()
log.SetOutput(&buf)
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
defer func() {
log.SetOutput(nil)
log.SetOutput(originalLogWriter)
}()

rootCmd.SetArgs([]string{"status", "--local", "--debug", "--verbose", "--config=gitctl.yaml"})
rootCmd.SetArgs([]string{"status", "--local", "--debug", "--verbose"})
err := rootCmd.Execute()

expected := "Configuration settings:"
Expand Down
30 changes: 25 additions & 5 deletions app/cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package cmd

import (
"github.com/bjoernkarma/gitctl/config"
"log"
"strings"

"github.com/bjoernkarma/gitctl/config"

"github.com/pkg/errors"

Expand Down Expand Up @@ -38,7 +40,9 @@ func Execute() error {
}

func init() {
cobra.OnInitialize(InitConfig)
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
return InitConfig()
}

rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is $HOME/.config/gitctl.yaml)")
rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "suppress output")
Expand All @@ -64,25 +68,39 @@ func init() {
rootCmd.AddCommand(pullCmd)
}

func InitConfig() {
func InitConfig() error {
if configFile != "" {
// Use config file from the flag.
viper.SetConfigFile(configFile)
} else {
workingDir, err := config.GitctlWorkingDir()
if err != nil {
return errors.Wrap(err, "failed to determine working directory")
}

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

viper.SetConfigName("gitctl")
viper.SetConfigType("yaml")
viper.AddConfigPath(config.GitctlWorkingDir())
viper.AddConfigPath(config.GitctlConfigDir())
viper.AddConfigPath(workingDir)
viper.AddConfigPath(configDir)
}

// Enable reading from environment variables
viper.SetEnvPrefix("GITCTL")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()

// Read the configuration file
if err := viper.ReadInConfig(); err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
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")
}
} else {
log.Printf("Using configuration file: %s", viper.ConfigFileUsed())
Expand All @@ -92,4 +110,6 @@ func InitConfig() {
if config.IsDebug() {
log.Printf("Configuration settings: %v", viper.AllSettings())
}

return nil
}
56 changes: 55 additions & 1 deletion app/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,27 @@ package cmd
import (
"bytes"
"log"
"os"
"path/filepath"
"testing"

"github.com/spf13/viper"
"github.com/stretchr/testify/assert"

"github.com/bjoernkarma/gitctl/config"
)

func TestRootCommandShowsHelp(t *testing.T) {
var buf bytes.Buffer
viper.Reset()
originalLogWriter := log.Writer()
log.SetOutput(&buf)
rootCmd.SetOut(&buf)
rootCmd.SetErr(&buf)
defer func() {
log.SetOutput(nil)
log.SetOutput(originalLogWriter)
configFile = ""
viper.Reset()
}()

rootCmd.SetArgs([]string{"--help"})
Expand All @@ -24,3 +33,48 @@ func TestRootCommandShowsHelp(t *testing.T) {
assert.Contains(t, buf.String(), expected, "expected %v to be contained in %v", expected, buf.String())
assert.NoError(t, err)
}

func TestCommandReturnsErrorForInvalidConfigFile(t *testing.T) {
viper.Reset()
tmpDir := t.TempDir()
invalidConfig := filepath.Join(tmpDir, "gitctl.yaml")
err := os.WriteFile(invalidConfig, []byte("verbosity: ["), 0600)
assert.NoError(t, err)

rootCmd.SetArgs([]string{"status", "--config", invalidConfig, "--local", "--dryRun"})
err = rootCmd.Execute()

assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read configuration file")

configFile = ""
viper.Reset()
}

func TestInitConfigReadsUnderscoreEnvVars(t *testing.T) {
viper.Reset()
configFile = ""
t.Setenv("GITCTL_RUN_MODE_LOCAL", "true")

err := InitConfig()

assert.NoError(t, err)
assert.True(t, config.IsLocal())

configFile = ""
viper.Reset()
}

func TestInitConfigReadsPrefixedVerbosityEnvVars(t *testing.T) {
viper.Reset()
configFile = ""
t.Setenv("GITCTL_VERBOSITY_VERBOSE", "true")

err := InitConfig()

assert.NoError(t, err)
assert.True(t, config.IsVerbose())

configFile = ""
viper.Reset()
}
18 changes: 9 additions & 9 deletions color/colorPrinter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (

const message = "test message"

func expectMessageIsPrinted(t *testing.T, buf bytes.Buffer, message string) {
func expectMessageIsPrinted(t *testing.T, buf bytes.Buffer) {
if !bytes.Contains(buf.Bytes(), []byte(message)) {
t.Errorf("expected test message to be printed, got %v", buf.String())
}
Expand Down Expand Up @@ -62,7 +62,7 @@ func TestPrintInfo_ColoredOutput(t *testing.T) {
viper.Set(config.GitCtlColor, true)
PrintInfo(message)

expectMessageIsPrinted(t, buf, message)
expectMessageIsPrinted(t, buf)
}

func TestPrintInfo_NonColoredOutput(t *testing.T) {
Expand All @@ -76,7 +76,7 @@ func TestPrintInfo_NonColoredOutput(t *testing.T) {
viper.Set(config.GitCtlColor, false)
PrintInfo(message)

expectMessageIsPrinted(t, buf, message)
expectMessageIsPrinted(t, buf)
}

func TestPrintSubtleInfo_QuietMode(t *testing.T) {
Expand All @@ -103,7 +103,7 @@ func TestPrintSubtleInfo_ColoredOutput(t *testing.T) {
viper.Set(config.GitCtlColor, true)
PrintSubtleInfo(message)

expectMessageIsPrinted(t, buf, message)
expectMessageIsPrinted(t, buf)
}

func TestPrintSubtleInfo_NonColoredOutput(t *testing.T) {
Expand All @@ -117,7 +117,7 @@ func TestPrintSubtleInfo_NonColoredOutput(t *testing.T) {
viper.Set(config.GitCtlColor, false)
PrintSubtleInfo(message)

expectMessageIsPrinted(t, buf, message)
expectMessageIsPrinted(t, buf)
}

func TestPrintSuccess_ColoredOutput(t *testing.T) {
Expand All @@ -131,7 +131,7 @@ func TestPrintSuccess_ColoredOutput(t *testing.T) {
viper.Set(config.GitCtlColor, true)
PrintSuccess(message)

expectMessageIsPrinted(t, buf, message)
expectMessageIsPrinted(t, buf)
}

func TestPrintSuccess_NonColoredOutput(t *testing.T) {
Expand All @@ -145,7 +145,7 @@ func TestPrintSuccess_NonColoredOutput(t *testing.T) {
viper.Set(config.GitCtlColor, false)
PrintSuccess(message)

expectMessageIsPrinted(t, buf, message)
expectMessageIsPrinted(t, buf)
}

func TestPrintError_ColoredOutput(t *testing.T) {
Expand All @@ -159,7 +159,7 @@ func TestPrintError_ColoredOutput(t *testing.T) {
viper.Set(config.GitCtlColor, true)
PrintError(message)

expectMessageIsPrinted(t, buf, message)
expectMessageIsPrinted(t, buf)
}

func TestPrintError_NonColoredOutput(t *testing.T) {
Expand All @@ -173,7 +173,7 @@ func TestPrintError_NonColoredOutput(t *testing.T) {
viper.Set(config.GitCtlColor, false)
PrintError(message)

expectMessageIsPrinted(t, buf, message)
expectMessageIsPrinted(t, buf)
}

// Simulating an error when writing to the output
Expand Down
Loading
Loading