diff --git a/README.md b/README.md index 044386655c3..09f09f4d615 100644 --- a/README.md +++ b/README.md @@ -596,7 +596,7 @@ See the [docs](docs/Custom_Command_Keybindings.md) ### Git flow support -Lazygit supports [Gitflow](https://github.com/nvie/gitflow) if you have it installed. To understand how the Gitflow model works check out Vincent Driessen's original [post](https://nvie.com/posts/a-successful-git-branching-model/) explaining it. To view Gitflow options from within Lazygit, press `i` from within the branches view. +Lazygit supports [Gitflow](https://github.com/nvie/gitflow) (or [git-flow-next](https://github.com/gittower/git-flow-next)) if you have it installed. To understand how the Gitflow model works check out Vincent Driessen's original [post](https://nvie.com/posts/a-successful-git-branching-model/) explaining it. To view Gitflow options from within Lazygit, press `i` from within the branches view. ## Contributing diff --git a/pkg/commands/git_commands/config.go b/pkg/commands/git_commands/config.go index a72fe504cc6..19f6dcaf5a7 100644 --- a/pkg/commands/git_commands/config.go +++ b/pkg/commands/git_commands/config.go @@ -1,6 +1,7 @@ package git_commands import ( + "regexp" "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_config" @@ -112,8 +113,66 @@ func (self *ConfigCommands) Branches(cmd oscommands.ICmdObjBuilder) map[string]* return result } -func (self *ConfigCommands) GetGitFlowPrefixes() string { - return self.gitConfig.GetGeneral("--local --get-regexp gitflow.prefix") +// git-flow config key patterns: legacy uses gitflow.prefix., git-flow-next uses gitflow.branch..prefix +const ( + gitFlowLegacyConfigArgs = "--local --get-regexp gitflow.prefix" + gitFlowNextConfigArgs = "--local --get-regexp gitflow\\.branch\\..*\\.prefix" +) + +func (self *ConfigCommands) getGitFlowPrefixes() string { + return self.gitConfig.GetGeneral(gitFlowLegacyConfigArgs) +} + +func (self *ConfigCommands) getGitFlowNextPrefixes() string { + return self.gitConfig.GetGeneral(gitFlowNextConfigArgs) +} + +// parseGitFlowLines parses lines matching re (submatch 1 = branch type, 2 = prefix) into prefixToType. +// When overwrite is false, existing keys are left unchanged so legacy entries win over next. +func parseGitFlowLines(output string, re *regexp.Regexp, prefixToType map[string]string, overwrite bool) { + for line := range strings.SplitSeq(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if m := re.FindStringSubmatch(line); len(m) == 3 { + prefix := normalizeGitFlowPrefix(m[2]) + if prefix == "" { + continue + } + if overwrite || prefixToType[prefix] == "" { + prefixToType[prefix] = m[1] + } + } + } +} + +// parseGitFlowPrefixMap parses legacy and git-flow-next config output into a unified prefix → branchType map. +// Legacy line format: "gitflow.prefix. " +// Next line format: "gitflow.branch..prefix " +// Prefixes are normalized to end in "/". Legacy entries win on duplicate prefix. +func parseGitFlowPrefixMap(legacyOutput, nextOutput string) map[string]string { + legacyRegexp := regexp.MustCompile(`gitflow\.prefix\.(\S+)\s+(.*)`) + nextRegexp := regexp.MustCompile(`gitflow\.branch\.([^.]+)\.prefix\s+(.*)`) + prefixToType := make(map[string]string) + parseGitFlowLines(legacyOutput, legacyRegexp, prefixToType, true) + parseGitFlowLines(nextOutput, nextRegexp, prefixToType, false) + return prefixToType +} + +func normalizeGitFlowPrefix(prefix string) string { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + return "" + } + if !strings.HasSuffix(prefix, "/") { + return prefix + "/" + } + return prefix +} + +func (self *ConfigCommands) GetGitFlowPrefixMap() map[string]string { + return parseGitFlowPrefixMap(self.getGitFlowPrefixes(), self.getGitFlowNextPrefixes()) } func (self *ConfigCommands) GetCoreCommentChar() byte { diff --git a/pkg/commands/git_commands/config_test.go b/pkg/commands/git_commands/config_test.go new file mode 100644 index 00000000000..4ec121aed2d --- /dev/null +++ b/pkg/commands/git_commands/config_test.go @@ -0,0 +1,127 @@ +package git_commands + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/git_config" + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestParseGitFlowPrefixMap(t *testing.T) { + type scenario struct { + testName string + legacyOutput string + nextOutput string + expected map[string]string + } + scenarios := []scenario{ + { + testName: "empty inputs", + legacyOutput: "", + nextOutput: "", + expected: map[string]string{}, + }, + { + testName: "legacy only", + legacyOutput: "gitflow.prefix.feature feature/\ngitflow.prefix.hotfix hotfix/", + nextOutput: "", + expected: map[string]string{"feature/": "feature", "hotfix/": "hotfix"}, + }, + { + testName: "next only", + legacyOutput: "", + nextOutput: "gitflow.branch.feature.prefix feature/\ngitflow.branch.release.prefix release/", + expected: map[string]string{"feature/": "feature", "release/": "release"}, + }, + { + testName: "legacy wins on duplicate prefix", + legacyOutput: "gitflow.prefix.foo feature/", + nextOutput: "gitflow.branch.bar.prefix feature/", + expected: map[string]string{"feature/": "foo"}, + }, + { + testName: "prefix normalized with trailing slash from legacy", + legacyOutput: "gitflow.prefix.feature feature", + nextOutput: "", + expected: map[string]string{"feature/": "feature"}, + }, + { + testName: "malformed legacy lines skipped", + legacyOutput: "gitflow.prefix.feature feature/\nnot-a-valid-line\ngitflow.prefix.hotfix hotfix/", + nextOutput: "", + expected: map[string]string{"feature/": "feature", "hotfix/": "hotfix"}, + }, + { + testName: "blank lines and whitespace ignored", + legacyOutput: " \n gitflow.prefix.feature feature/ \n \n ", + nextOutput: "", + expected: map[string]string{"feature/": "feature"}, + }, + } + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + got := parseGitFlowPrefixMap(s.legacyOutput, s.nextOutput) + assert.Equal(t, s.expected, got) + }) + } +} + +func TestGetGitFlowPrefixMap(t *testing.T) { + type scenario struct { + testName string + gitConfigMockResponses map[string]string + expected map[string]string + } + scenarios := []scenario{ + { + testName: "empty when both queries empty", + gitConfigMockResponses: nil, + expected: map[string]string{}, + }, + { + testName: "correct map from legacy-only output", + gitConfigMockResponses: map[string]string{ + "--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature/\ngitflow.prefix.hotfix hotfix/", + }, + expected: map[string]string{"feature/": "feature", "hotfix/": "hotfix"}, + }, + { + testName: "correct map from git-flow-next-only output", + gitConfigMockResponses: map[string]string{ + "--local --get-regexp gitflow\\.branch\\..*\\.prefix": "gitflow.branch.feature.prefix feature/\ngitflow.branch.release.prefix release/", + }, + expected: map[string]string{"feature/": "feature", "release/": "release"}, + }, + { + testName: "merged map with legacy winning when both have same prefix", + gitConfigMockResponses: map[string]string{ + "--local --get-regexp gitflow.prefix": "gitflow.prefix.foo feature/", + "--local --get-regexp gitflow\\.branch\\..*\\.prefix": "gitflow.branch.bar.prefix feature/", + }, + expected: map[string]string{"feature/": "foo"}, + }, + { + testName: "prefix normalized with trailing slash", + gitConfigMockResponses: map[string]string{ + "--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature", + }, + expected: map[string]string{"feature/": "feature"}, + }, + { + testName: "malformed lines skipped", + gitConfigMockResponses: map[string]string{ + "--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature/\nnot-a-valid-line\n", + }, + expected: map[string]string{"feature/": "feature"}, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + config := NewConfigCommands(common.NewDummyCommon(), git_config.NewFakeGitConfig(s.gitConfigMockResponses)) + got := config.GetGitFlowPrefixMap() + assert.Equal(t, s.expected, got) + }) + } +} diff --git a/pkg/commands/git_commands/flow.go b/pkg/commands/git_commands/flow.go index fc00c11a171..ccf0149de44 100644 --- a/pkg/commands/git_commands/flow.go +++ b/pkg/commands/git_commands/flow.go @@ -1,7 +1,6 @@ package git_commands import ( - "regexp" "strings" "github.com/go-errors/errors" @@ -21,30 +20,19 @@ func NewFlowCommands( } func (self *FlowCommands) GitFlowEnabled() bool { - return self.config.GetGitFlowPrefixes() != "" + return len(self.config.GetGitFlowPrefixMap()) > 0 } func (self *FlowCommands) FinishCmdObj(branchName string) (*oscommands.CmdObj, error) { - prefixes := self.config.GetGitFlowPrefixes() + prefixMap := self.config.GetGitFlowPrefixMap() - // need to find out what kind of branch this is - prefix := strings.SplitAfterN(branchName, "/", 2)[0] - suffix := strings.Replace(branchName, prefix, "", 1) - - branchType := "" - for line := range strings.SplitSeq(strings.TrimSpace(prefixes), "\n") { - if strings.HasPrefix(line, "gitflow.prefix.") && strings.HasSuffix(line, prefix) { - - regex := regexp.MustCompile("gitflow.prefix.([^ ]*) .*") - matches := regex.FindAllStringSubmatch(line, 1) - - if len(matches) > 0 && len(matches[0]) > 1 { - branchType = matches[0][1] - break - } - } + prefixPart, suffix, ok := strings.Cut(branchName, "/") + if !ok || prefixPart == "" || suffix == "" { + return nil, errors.New(self.Tr.NotAGitFlowBranch) } + prefix := prefixPart + "/" + branchType := prefixMap[prefix] if branchType == "" { return nil, errors.New(self.Tr.NotAGitFlowBranch) } diff --git a/pkg/commands/git_commands/flow_test.go b/pkg/commands/git_commands/flow_test.go index 911f50c7eb4..2dab0a43e31 100644 --- a/pkg/commands/git_commands/flow_test.go +++ b/pkg/commands/git_commands/flow_test.go @@ -7,17 +7,56 @@ import ( "github.com/stretchr/testify/assert" ) +func TestGitFlowEnabled(t *testing.T) { + type scenario struct { + testName string + expected bool + gitConfigMockResponses map[string]string + } + scenarios := []scenario{ + { + testName: "disabled when no config", + expected: false, + gitConfigMockResponses: nil, + }, + { + testName: "enabled with legacy config", + expected: true, + gitConfigMockResponses: map[string]string{ + "--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature/", + }, + }, + { + testName: "enabled with git-flow-next only config", + expected: true, + gitConfigMockResponses: map[string]string{ + "--local --get-regexp gitflow\\.branch\\..*\\.prefix": "gitflow.branch.feature.prefix feature/", + }, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + instance := buildFlowCommands(commonDeps{ + gitConfig: git_config.NewFakeGitConfig(s.gitConfigMockResponses), + }) + assert.Equal(t, s.expected, instance.GitFlowEnabled()) + }) + } +} + func TestStartCmdObj(t *testing.T) { - scenarios := []struct { + type scenario struct { testName string branchType string - name string + branchName string expected []string - }{ + } + scenarios := []scenario{ { testName: "basic", branchType: "feature", - name: "test", + branchName: "test", expected: []string{"git", "flow", "feature", "start", "test"}, }, } @@ -27,7 +66,7 @@ func TestStartCmdObj(t *testing.T) { instance := buildFlowCommands(commonDeps{}) assert.Equal(t, - instance.StartCmdObj(s.branchType, s.name).Args(), + instance.StartCmdObj(s.branchType, s.branchName).Args(), s.expected, ) }) @@ -35,13 +74,14 @@ func TestStartCmdObj(t *testing.T) { } func TestFinishCmdObj(t *testing.T) { - scenarios := []struct { + type scenario struct { testName string branchName string expected []string expectedError string gitConfigMockResponses map[string]string - }{ + } + scenarios := []scenario{ { testName: "not a git flow branch", branchName: "mybranch", @@ -57,7 +97,7 @@ func TestFinishCmdObj(t *testing.T) { gitConfigMockResponses: nil, }, { - testName: "feature branch with config", + testName: "feature branch with legacy config", branchName: "feature/mybranch", expected: []string{"git", "flow", "feature", "finish", "mybranch"}, expectedError: "", @@ -65,6 +105,25 @@ func TestFinishCmdObj(t *testing.T) { "--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature/", }, }, + { + testName: "feature branch with git-flow-next only config", + branchName: "feature/mybranch", + expected: []string{"git", "flow", "feature", "finish", "mybranch"}, + expectedError: "", + gitConfigMockResponses: map[string]string{ + "--local --get-regexp gitflow\\.branch\\..*\\.prefix": "gitflow.branch.feature.prefix feature/", + }, + }, + { + testName: "legacy wins when both configs have same prefix", + branchName: "feature/mybranch", + expected: []string{"git", "flow", "foo", "finish", "mybranch"}, + expectedError: "", + gitConfigMockResponses: map[string]string{ + "--local --get-regexp gitflow.prefix": "gitflow.prefix.foo feature/", + "--local --get-regexp gitflow\\.branch\\..*\\.prefix": "gitflow.branch.bar.prefix feature/", + }, + }, } for _, s := range scenarios { @@ -76,15 +135,12 @@ func TestFinishCmdObj(t *testing.T) { cmd, err := instance.FinishCmdObj(s.branchName) if s.expectedError != "" { - if err == nil { - t.Errorf("Expected error, got nil") - } else { - assert.Equal(t, err.Error(), s.expectedError) - } - } else { - assert.NoError(t, err) - assert.Equal(t, cmd.Args(), s.expected) + assert.Error(t, err) + assert.Equal(t, s.expectedError, err.Error()) + return } + assert.NoError(t, err) + assert.Equal(t, s.expected, cmd.Args()) }) } }