Add support for git flow using git-flow-next

git-flow-next (https://github.com/gittower/git-flow-next) uses a
different config schema than legacy git-flow:
gitflow.branch.<type>.prefix instead of gitflow.prefix.<type>.
Recognize both schemas in GetGitFlowPrefixMap by querying each and
merging into a single prefix → branchType map. GitFlowEnabled now
consults the merged map so a next-only setup counts as enabled.

When both schemas configure the same prefix, the legacy entry wins.
In normal usage both schemas agree, so the rule mainly matters as a
deterministic tie-breaker.
This commit is contained in:
Henry Maddocks
2026-05-26 11:57:09 +02:00
committed by Stefan Haller
parent 415015c66a
commit dd0d90837d
5 changed files with 147 additions and 36 deletions
+1 -1
View File
@@ -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
+33 -11
View File
@@ -113,28 +113,50 @@ 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.<type>, git-flow-next uses gitflow.branch.<type>.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)
}
// parseGitFlowPrefixMap parses git-flow config output into a prefix → branchType map.
// Line format: "gitflow.prefix.<type> <prefix>". Prefixes are normalized to end in "/".
func parseGitFlowPrefixMap(legacyOutput string) map[string]string {
legacyRegexp := regexp.MustCompile(`gitflow\.prefix\.(\S+)\s+(.*)`)
prefixToType := make(map[string]string)
for line := range strings.SplitSeq(legacyOutput, "\n") {
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 := legacyRegexp.FindStringSubmatch(line); len(m) == 3 {
if m := re.FindStringSubmatch(line); len(m) == 3 {
prefix := normalizeGitFlowPrefix(m[2])
if prefix == "" {
continue
}
prefixToType[prefix] = m[1]
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.<type> <prefix>"
// Next line format: "gitflow.branch.<type>.prefix <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
}
@@ -150,7 +172,7 @@ func normalizeGitFlowPrefix(prefix string) string {
}
func (self *ConfigCommands) GetGitFlowPrefixMap() map[string]string {
return parseGitFlowPrefixMap(self.GetGitFlowPrefixes())
return parseGitFlowPrefixMap(self.getGitFlowPrefixes(), self.getGitFlowNextPrefixes())
}
func (self *ConfigCommands) GetCoreCommentChar() byte {
+40 -7
View File
@@ -12,38 +12,56 @@ func TestParseGitFlowPrefixMap(t *testing.T) {
type scenario struct {
testName string
legacyOutput string
nextOutput string
expected map[string]string
}
scenarios := []scenario{
{
testName: "empty input",
testName: "empty inputs",
legacyOutput: "",
nextOutput: "",
expected: map[string]string{},
},
{
testName: "feature and hotfix",
testName: "legacy only",
legacyOutput: "gitflow.prefix.feature feature/\ngitflow.prefix.hotfix hotfix/",
nextOutput: "",
expected: map[string]string{"feature/": "feature", "hotfix/": "hotfix"},
},
{
testName: "prefix normalized with trailing slash",
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 lines skipped",
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)
got := parseGitFlowPrefixMap(s.legacyOutput, s.nextOutput)
assert.Equal(t, s.expected, got)
})
}
@@ -57,17 +75,32 @@ func TestGetGitFlowPrefixMap(t *testing.T) {
}
scenarios := []scenario{
{
testName: "empty when no config",
testName: "empty when both queries empty",
gitConfigMockResponses: nil,
expected: map[string]string{},
},
{
testName: "correct map from legacy output",
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{
+1 -1
View File
@@ -20,7 +20,7 @@ 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) {
+72 -16
View File
@@ -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())
})
}
}