diff --git a/cmd/commit/main.go b/cmd/commit/main.go index bbf11cd..3724d3f 100644 --- a/cmd/commit/main.go +++ b/cmd/commit/main.go @@ -26,6 +26,14 @@ func main() { "Path to the config json file", ) + var showConfig bool + flag.BoolVar( + &showConfig, + "show-config", + false, + "Prints out the current config", + ) + var dryRun bool flag.BoolVar( &dryRun, @@ -36,7 +44,6 @@ func main() { flag.Parse() - commitMessage := getCommitMessage() repo := openRepo() worktree := openWorktree(repo) @@ -47,6 +54,21 @@ func main() { ) } + fileReader := config.FileReader{} + + if showConfig { + configJSON, err := config.EncodeConfigAtPath(fileReader, configFilePath) + if err == nil { + fmt.Fprintf(os.Stdout, "%s\n", configJSON) + os.Exit(0) + } else { + fmt.Fprintf(os.Stderr, helpers.Red("Failed to parse config: %v\n"), err) + os.Exit(1) + } + } + + commitMessage := getCommitMessage() + head, err := repo.Reference(plumbing.HEAD, false) if err != nil { fmt.Fprintf(os.Stderr, helpers.Red("Failed to read HEAD: %v\n"), err) @@ -57,8 +79,8 @@ func main() { if head.Type() == plumbing.SymbolicReference && head.Target().IsBranch() { branchName := head.Target().Short() - fileReader := config.FileReader{} - cfg, err := config.ReadCommitConfig(fileReader, configFilePath) + isValidating := true + cfg, err := config.ReadCommitConfig(fileReader, configFilePath, isValidating) if err != nil { fmt.Fprintf(os.Stderr, helpers.Red("Failed to read config: %v\n"), err) os.Exit(1) @@ -131,7 +153,6 @@ func makeCommitOptions(usr user.User) git.CommitOptions { // Commits changes with provided message func commitChanges(repo *git.Repository, worktree *git.Worktree, commitMessage string) { - checkStagedChanges(worktree) usr := user.GetUser(*repo) diff --git a/docs/README.md b/docs/README.md index 73bd0ea..e3bed79 100644 --- a/docs/README.md +++ b/docs/README.md @@ -85,6 +85,11 @@ If you don't want to include the `.commit.json` file at the root of your reposit ```shell commit -config-path=${HOME}/.config/.commit.json "Finally fix everything" ``` +### Show Current Config +To see the config that the tool will apply, use `--show-config`: +```shell +commit --show-config +``` ### Multiple Issue Numbers If the branch has multiple issues in its name, the tool will include them all, comma-separated. For example, the branch named `add-tests-for-CR-127-and-CR-131-features`, the issue regex set to `[A-Z]{2}-[0-9]+`, and the "outputIssuePrefix" and "outputIssueSuffix" settings for the output set to `[` and `]:`, the generated commit message would start with the following: diff --git a/e2e.sh b/e2e.sh index 15b7d63..52b0509 100755 --- a/e2e.sh +++ b/e2e.sh @@ -345,6 +345,46 @@ test_commit_from_another_worktree() { pass_test $TESTNAME } +test_show_config() { + TESTNAME="test_show_config" + start_test $TESTNAME + + setup_test_repository &&\ + git checkout -b feature/H22-handle-edge-cases && \ + + # Write a config file + echo ' + { + "issueRegex": "H[0-9]+", + "outputIssuePrefix": "[", + "outputIssueSuffix": "]", + "outputStringPrefix": "", + "outputStringSuffix": " " + } + ' > .commit.json && \ + + # Show the config using the CLI + CONFIG_OUTPUT=$(../bin/commit --show-config) + + EXPECTED_CONFIG='{ + "issueRegex": "H[0-9]+", + "outputIssuePrefix": "[", + "outputIssueSuffix": "]", + "outputStringPrefix": "", + "outputStringSuffix": " " +}' + + if [ "$CONFIG_OUTPUT" != "$EXPECTED_CONFIG" ]; then + echo "Expected config output:" + echo "$EXPECTED_CONFIG" + echo "Actual config output:" + echo "$CONFIG_OUTPUT" + fail_test $TESTNAME + fi + + pass_test $TESTNAME +} + # MARK: - Run Tests build_if_needed @@ -356,3 +396,4 @@ test_set_correct_author test_use_config_with_empty_regex test_commit_with_detached_head test_commit_from_another_worktree +test_show_config diff --git a/internal/config/config.go b/internal/config/config.go index 8663208..e5359a0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "encoding/json" "errors" "regexp" @@ -27,7 +28,7 @@ type commitConfigDTO struct { } // Reads config at the file path and unmarshals it into commitConfig struct -func ReadCommitConfig(fileReader FileReading, configFilePath string) (CommitConfig, error) { +func ReadCommitConfig(fileReader FileReading, configFilePath string, isValidating bool) (CommitConfig, error) { var cfgDto commitConfigDTO _, err := fileReader.Stat(configFilePath) @@ -38,21 +39,59 @@ func ReadCommitConfig(fileReader FileReading, configFilePath string) (CommitConf return CommitConfig{}, err } - err = json.Unmarshal(file, &cfgDto) - if err != nil { - return CommitConfig{}, err + if len(bytes.TrimSpace(file)) > 0 { + err = json.Unmarshal(file, &cfgDto) + if err != nil { + return CommitConfig{}, err + } + } else if isValidating { + return CommitConfig{}, validateRegex("") + } else { + return CommitConfig{}, nil } } cfg := makeConfig(cfgDto) - if err := validateRegex(cfg.IssueRegex); err != nil { - return CommitConfig{}, err + if isValidating { + if err := validateRegex(cfg.IssueRegex); err != nil { + return CommitConfig{}, err + } } return cfg, nil } +// Encodes config at the given file path into JSON +func EncodeConfigAtPath(fileReader FileReading, configFilePath string) ([]byte, error) { + isValidating := false + cfg, err := ReadCommitConfig(fileReader, configFilePath, isValidating) + if err != nil { + return nil, err + } + + cfgDto := commitConfigDTO{ + IssueRegex: &cfg.IssueRegex, + OutputIssuePrefix: &cfg.OutputIssuePrefix, + OutputIssueSuffix: &cfg.OutputIssueSuffix, + OutputStringPrefix: &cfg.OutputStringPrefix, + OutputStringSuffix: &cfg.OutputStringSuffix, + } + + var cfgBuffer bytes.Buffer + encoder := json.NewEncoder(&cfgBuffer) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + + if err := encoder.Encode(cfgDto); err != nil { + return nil, err + } + + configJson := bytes.TrimSuffix(cfgBuffer.Bytes(), []byte("\n")) + + return configJson, nil +} + // Helper function to create a default config func MakeDefaultConfig() CommitConfig { return CommitConfig{ diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c64866e..a9491b3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "reflect" + "strings" "testing" "github.com/artem-y/commit/internal/config" @@ -21,7 +22,7 @@ func Test_ReadCommitConfig_WhenFileDoesNotExist_ReturnsDefaultConfig(t *testing. defaultConfig := config.MakeDefaultConfig() // Act - cfg, err := config.ReadCommitConfig(mock, "some/path") + cfg, err := config.ReadCommitConfig(mock, "some/path", true) // Assert for _, invocation := range mock.Invocations { @@ -55,7 +56,7 @@ func Test_ReadCommitConfig_WhenFilledWithValidSettings_LoadsAllValuesFromConfig( mock.Results.ReadFile.Success = []byte(configJson) // Act - cfg, err := config.ReadCommitConfig(mock, "some/path") + cfg, err := config.ReadCommitConfig(mock, "some/path", true) // Assert if err != nil { @@ -71,13 +72,57 @@ func Test_ReadCommitConfig_WhenFilledWithValidSettings_LoadsAllValuesFromConfig( } } +func Test_ReadCommitConfig_ValidatingEmptyFile_ReturnsError(t *testing.T) { + // Arrange + var mock *mocks.FileReadingMock = &mocks.FileReadingMock{} + mock.Results.ReadFile.Success = []byte(" ") + + // Act + _, err := config.ReadCommitConfig(mock, "path/to/empty/file", true) + + // Assert + if err == nil { + t.Error("Expected an error, got `nil`") + } + expectedErr := "Issue regex can't be empty. Please update the config file." + if err.Error() != expectedErr { + t.Errorf( + "Expected error '%s', got '%s'", + expectedErr, + err.Error(), + ) + } +} + +func Test_ReadCommitConfig_WithoutValidatingEmptyFile_ReturnsEmptyConfig(t *testing.T) { + // Arrange + var mock *mocks.FileReadingMock = &mocks.FileReadingMock{} + mock.Results.ReadFile.Success = []byte("") + + // Act + cfg, err := config.ReadCommitConfig(mock, "file/path", false) + + // Assert + if err != nil { + t.Errorf("Expected no error, got `%s`", err.Error()) + } + expectedConfig := config.CommitConfig{} + if !reflect.DeepEqual(cfg, expectedConfig) { + t.Errorf( + "Expected `%s', got `%s`", + makeJSON(expectedConfig), + makeJSON(cfg), + ) + } +} + func Test_ReadCommitConfig_WhenInvalidJson_ReturnsError(t *testing.T) { // Arrange var mock *mocks.FileReadingMock = &mocks.FileReadingMock{} mock.Results.ReadFile.Success = []byte("{invalid json}") // Act - _, err := config.ReadCommitConfig(mock, "some/path") + _, err := config.ReadCommitConfig(mock, "some/path", true) // Assert if err == nil { @@ -91,7 +136,7 @@ func Test_ReadCommitConfig_WhenFailedToReadFile_ReturnsError(t *testing.T) { mock.Results.ReadFile.Error = errors.New("failed to read file") // Act - _, err := config.ReadCommitConfig(mock, "some/path") + _, err := config.ReadCommitConfig(mock, "some/path", true) // Assert if err == nil { @@ -114,7 +159,7 @@ func Test_ReadCommitConfig_WhenOnlyRegexInConfix_ReturnsConfigWithRegex(t *testi expectedConfig.IssueRegex = expectedRegex // Act - cfg, err := config.ReadCommitConfig(mock, "some/path") + cfg, err := config.ReadCommitConfig(mock, "some/path", true) // Assert if err != nil { @@ -145,13 +190,12 @@ func Test_ReadCommitConfig_WhenIssueRegexIsEmpty_ReturnsError(t *testing.T) { mock.Results.ReadFile.Success = []byte(configJson) // Act - _, err := config.ReadCommitConfig(mock, "some/path") + _, err := config.ReadCommitConfig(mock, "some/path", true) // Assert if err == nil { t.Error("Expected an error, got `nil`") } - } func Test_ReadCommitConfig_WhenIssueRegexIsInvalid_ReturnsError(t *testing.T) { @@ -161,13 +205,12 @@ func Test_ReadCommitConfig_WhenIssueRegexIsInvalid_ReturnsError(t *testing.T) { mock.Results.ReadFile.Success = []byte(configJson) // Act - _, err := config.ReadCommitConfig(mock, "some/path") + _, err := config.ReadCommitConfig(mock, "some/path", true) // Assert if err == nil { t.Error("Expected an error, got `nil`") } - } func Test_MakeDefaultConfig_CreatesConfigWithDefaultValues(t *testing.T) { @@ -193,6 +236,98 @@ func Test_MakeDefaultConfig_CreatesConfigWithDefaultValues(t *testing.T) { } } +func Test_EncodeConfigAtPath_WithValidConfig_ReturnsConfigAsJson(t *testing.T) { + // Arrange + var mock *mocks.FileReadingMock = &mocks.FileReadingMock{} + expectedConfig := `{ + "issueRegex": "SWE-[0-9]+", + "outputIssuePrefix": "(", + "outputIssueSuffix": ")", + "outputStringPrefix": "(( ", + "outputStringSuffix": " )) " +}` + + mock.Results.ReadFile.Success = []byte(expectedConfig) + + // Act + cfg, err := config.EncodeConfigAtPath(mock, "some/path") + // Assert + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if !reflect.DeepEqual(cfg, mock.Results.ReadFile.Success) { + t.Errorf( + "Expected config JSON ('%s'), got '%s'", + string(mock.Results.ReadFile.Success), + string(cfg), + ) + } +} + +func Test_EncodeConfigAtPath_WhenFailedToReadFile_ReturnsError(t *testing.T) { + // Arrange + var mock *mocks.FileReadingMock = &mocks.FileReadingMock{} + expectedErrorMessage := "Error: Failed to read file" + mock.Results.ReadFile.Error = errors.New(expectedErrorMessage) + + // Act + cfg, err := config.EncodeConfigAtPath(mock, "a/path") + + // Assert + if cfg != nil { + t.Errorf("Expected no config, got %v", cfg) + } + if err == nil { + t.Error("Expected an error, got `nil`") + } + if err.Error() != expectedErrorMessage { + t.Errorf("Expected error '%s', got '%v'", expectedErrorMessage, err) + } +} + +func Test_EncodeConfigAtPath_WithInvalidJSON_ReturnsError(t *testing.T) { + // Arrange + var mock *mocks.FileReadingMock = &mocks.FileReadingMock{} + configJsonWithInvalidRegex := "{\"issueRegex\":\"abc}" + mock.Results.ReadFile.Success = []byte(configJsonWithInvalidRegex) + + // Act + cfg, err := config.EncodeConfigAtPath(mock, "path/to/invalid/config") + + // Assert + if cfg != nil { + t.Errorf("Expected no config, got %v", cfg) + } + if err == nil { + t.Error("Expected an error, got 'nil'") + } +} + +func Test_EncodeConfigAtPath_WhenConfigContainsUnicodeEscapableCharacters_DoesNotEscapeCharacter(t *testing.T) { + // Arrange + var mock *mocks.FileReadingMock = &mocks.FileReadingMock{} + mock.Results.ReadFile.Success = []byte(`{ + "issueRegex": "CORE_[0-9]+", + "outputIssuePrefix": "<", + "outputIssueSuffix": ">", + "outputStringPrefix": "<< ", + "outputStringSuffix": " >> " +}`) + + // Act + cfg, err := config.EncodeConfigAtPath(mock, "some/path/to/config") + + // Assert + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if strings.Contains(string(cfg), "\\u003c") || strings.Contains(string(cfg), "\\u003e") { + t.Errorf("Expected '<' and '>' to stay unescaped, got '%s'", string(cfg)) + } +} + // Helper function to create a JSON string from a config func makeJSON(cfg config.CommitConfig) string { return fmt.Sprintf(