Skip to content
Open
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
90 changes: 84 additions & 6 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,14 @@ If git-flow-avh configuration exists, it will be imported.`,
globalScope, _ := cmd.Flags().GetBool("global")
systemScope, _ := cmd.Flags().GetBool("system")
fileScope, _ := cmd.Flags().GetString("file")
InitCommand(useDefaults, !noCreateBranches, force, preset, custom, mainBranch, developBranch, featurePrefix, bugfixPrefix, releasePrefix, hotfixPrefix, supportPrefix, tagPrefix, localScope, globalScope, systemScope, fileScope)
sharedScope, _ := cmd.Flags().GetBool("shared")
InitCommand(useDefaults, !noCreateBranches, force, preset, custom, mainBranch, developBranch, featurePrefix, bugfixPrefix, releasePrefix, hotfixPrefix, supportPrefix, tagPrefix, localScope, globalScope, systemScope, fileScope, sharedScope)
},
}

// InitCommand is the implementation of the init command
func InitCommand(useDefaults, createBranches, force bool, preset string, custom bool, mainBranch, developBranch, featurePrefix, bugfixPrefix, releasePrefix, hotfixPrefix, supportPrefix, tagPrefix string, localScope, globalScope, systemScope bool, fileScope string) {
if err := initFlow(useDefaults, createBranches, force, preset, custom, mainBranch, developBranch, featurePrefix, bugfixPrefix, releasePrefix, hotfixPrefix, supportPrefix, tagPrefix, localScope, globalScope, systemScope, fileScope); err != nil {
func InitCommand(useDefaults, createBranches, force bool, preset string, custom bool, mainBranch, developBranch, featurePrefix, bugfixPrefix, releasePrefix, hotfixPrefix, supportPrefix, tagPrefix string, localScope, globalScope, systemScope bool, fileScope string, sharedScope bool) {
if err := initFlow(useDefaults, createBranches, force, preset, custom, mainBranch, developBranch, featurePrefix, bugfixPrefix, releasePrefix, hotfixPrefix, supportPrefix, tagPrefix, localScope, globalScope, systemScope, fileScope, sharedScope); err != nil {
var exitCode errors.ExitCode
if flowErr, ok := err.(errors.Error); ok {
exitCode = flowErr.ExitCode()
Expand All @@ -69,7 +70,7 @@ func InitCommand(useDefaults, createBranches, force bool, preset string, custom
}

// initFlow performs the actual initialization logic and returns any errors
func initFlow(useDefaults, createBranches, force bool, preset string, custom bool, mainBranch, developBranch, featurePrefix, bugfixPrefix, releasePrefix, hotfixPrefix, supportPrefix, tagPrefix string, localScope, globalScope, systemScope bool, fileScope string) error {
func initFlow(useDefaults, createBranches, force bool, preset string, custom bool, mainBranch, developBranch, featurePrefix, bugfixPrefix, releasePrefix, hotfixPrefix, supportPrefix, tagPrefix string, localScope, globalScope, systemScope bool, fileScope string, sharedScope bool) error {
// Validate mutual exclusivity of scope flags
scopeCount := 0
if localScope {
Expand All @@ -84,8 +85,11 @@ func initFlow(useDefaults, createBranches, force bool, preset string, custom boo
if fileScope != "" {
scopeCount++
}
if sharedScope {
scopeCount++
}
if scopeCount > 1 {
return fmt.Errorf("cannot use multiple scope options together; specify only one of --local, --global, --system, or --file")
return fmt.Errorf("cannot use multiple scope options together; specify only one of --local, --global, --system, --file, or --shared")
}

// Determine config scope
Expand All @@ -104,6 +108,14 @@ func initFlow(useDefaults, createBranches, force bool, preset string, custom boo
if _, err := os.Stat(dir); os.IsNotExist(err) {
return fmt.Errorf("config file directory does not exist: %s", dir)
}
case sharedScope:
// --shared writes to .gitflow in the repository root
topLevel, err := git.GetRepoTopLevel()
if err != nil {
return fmt.Errorf("could not determine repository root for --shared: %w", err)
}
scope = git.ConfigScopeFile
scopeFile = filepath.Join(topLevel, sharedGitFlowFile)
case localScope:
scope = git.ConfigScopeLocal
default:
Expand All @@ -115,6 +127,13 @@ func initFlow(useDefaults, createBranches, force bool, preset string, custom boo
return &errors.GitError{Operation: "check if git repository", Err: fmt.Errorf("not a git repository. Please run 'git init' first")}
}

// For the default scope, auto-wire .gitflow if it exists in the repository root
// and is not yet included in the local config. This mirrors what PersistentPreRun
// does for other commands, ensuring `git flow init` sees an existing shared config.
if scope == git.ConfigScopeDefault {
autoWireSharedGitFlowFile()
}

// Check if git-flow-next is already initialized at the specified scope
status, err := config.IsGitFlowNextInitializedWithScope(scope, scopeFile)
if err != nil {
Expand All @@ -134,7 +153,12 @@ func initFlow(useDefaults, createBranches, force bool, preset string, custom boo
case scope == git.ConfigScopeDefault && status.SourceScope == git.ConfigScopeSystem:
msg = "Git-flow is configured via system config. Use --local to create repo-specific config, or --force to reconfigure."
case scope == git.ConfigScopeDefault && status.SourceScope == git.ConfigScopeLocal:
msg = "Git-flow is already configured in this repository."
// Check whether the local config is wired to a shared .gitflow file
if sharedFile := detectedSharedConfigFile(); sharedFile != "" {
msg = fmt.Sprintf("Git-flow is configured via %s (shared repository config). Use --force to reconfigure.", sharedFile)
} else {
msg = "Git-flow is already configured in this repository."
}
case scope == git.ConfigScopeLocal:
msg = "Git-flow is already configured in local config."
case scope == git.ConfigScopeGlobal:
Expand Down Expand Up @@ -236,6 +260,16 @@ func initFlow(useDefaults, createBranches, force bool, preset string, custom boo
return &errors.GitError{Operation: "mark repository as initialized", Err: err}
}

// When using --shared, wire .gitflow into the local .git/config via include.path
// so that git (and git-flow) automatically loads it going forward.
if sharedScope {
if err := wireSharedGitFlowFile(); err != nil {
// Non-fatal: config is saved; just warn the user
fmt.Fprintf(os.Stderr, "Warning: could not add include.path to local config: %v\n", err)
fmt.Fprintf(os.Stderr, "Run manually: git config --local --add include.path %s\n", sharedGitFlowIncludePath)
}
}

// Create branches if requested
if createBranches {
if err := createGitFlowBranches(cfg); err != nil {
Expand All @@ -244,6 +278,10 @@ func initFlow(useDefaults, createBranches, force bool, preset string, custom boo
}

fmt.Println("Git flow has been initialized")
if sharedScope {
fmt.Printf("Configuration saved to %s\n", scopeFile)
fmt.Println("Tip: commit .gitflow to share this configuration with your team.")
}
return nil
}

Expand Down Expand Up @@ -685,4 +723,44 @@ func init() {
initCmd.Flags().Bool("global", false, "Store configuration in user's global ~/.gitconfig")
initCmd.Flags().Bool("system", false, "Store configuration in system-wide /etc/gitconfig")
initCmd.Flags().String("file", "", "Store configuration in specified file")
initCmd.Flags().Bool("shared", false, "Store configuration in .gitflow at repository root (shareable via version control)")
}

// wireSharedGitFlowFile adds include.path = ../.gitflow to the local .git/config
// if it is not already present. Safe to call multiple times.
func wireSharedGitFlowFile() error {
includes, err := git.GetLocalConfigAllValues("include.path")
if err != nil {
return err
}
for _, inc := range includes {
if inc == sharedGitFlowIncludePath {
return nil // already wired
}
}
return git.AddLocalConfigValue("include.path", sharedGitFlowIncludePath)
}

// detectedSharedConfigFile returns the absolute path to the repository's .gitflow file
// if it exists in the repository root AND is currently wired via include.path in the
// local config. Returns an empty string otherwise.
func detectedSharedConfigFile() string {
topLevel, err := git.GetRepoTopLevel()
if err != nil {
return ""
}
gitflowPath := filepath.Join(topLevel, sharedGitFlowFile)
if _, err := os.Stat(gitflowPath); os.IsNotExist(err) {
return ""
}
includes, err := git.GetLocalConfigAllValues("include.path")
if err != nil {
return ""
}
for _, inc := range includes {
if inc == sharedGitFlowIncludePath {
return gitflowPath
}
}
return ""
}
55 changes: 55 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
package cmd

import (
"fmt"
"os"
"path/filepath"

"github.com/gittower/git-flow-next/internal/git"
"github.com/spf13/cobra"
)

// sharedGitFlowFile is the standard filename for a shared git-flow configuration
// stored in the repository root and committed alongside project code.
const sharedGitFlowFile = ".gitflow"

// sharedGitFlowIncludePath is the include.path value used in .git/config to load
// the shared .gitflow file. It is relative to .git/config (one directory up = repo root).
const sharedGitFlowIncludePath = "../.gitflow"

var rootCmd = &cobra.Command{
Use: "git-flow",
Short: "git-flow-next is a modern reimplementation of git-flow",
Expand All @@ -16,12 +29,54 @@ It provides a set of commands to work with Git branches according to the git-flo
git flow feature finish my-feature
git flow release start 1.0.0
git flow release finish 1.0.0`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Skip for the init command — it handles its own .gitflow detection and wiring.
if cmd.Name() == "init" {
return
}
autoWireSharedGitFlowFile()
},
Run: func(cmd *cobra.Command, args []string) {
// If no subcommand is provided, print help
cmd.Help()
},
}

// autoWireSharedGitFlowFile checks whether a .gitflow file exists in the repository
// root and, if it is not yet referenced in the local git config via include.path,
// adds the include automatically. This lets developers clone a repository and start
// using git-flow commands immediately without running git flow init.
func autoWireSharedGitFlowFile() {
if !git.IsGitRepo() {
return
}
topLevel, err := git.GetRepoTopLevel()
if err != nil {
return
}
gitflowPath := filepath.Join(topLevel, sharedGitFlowFile)
if _, err := os.Stat(gitflowPath); os.IsNotExist(err) {
return
}

// Check whether include.path already points to ../.gitflow in local config.
includes, err := git.GetLocalConfigAllValues("include.path")
if err != nil {
return
}
for _, inc := range includes {
if inc == sharedGitFlowIncludePath {
return // already wired — nothing to do
}
}

// Wire it up.
if err := git.AddLocalConfigValue("include.path", sharedGitFlowIncludePath); err != nil {
return
}
fmt.Fprintf(os.Stderr, "Note: Found %s in repository root, auto-configured git-flow.\n", sharedGitFlowFile)
}

// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() error {
Expand Down
15 changes: 13 additions & 2 deletions docs/git-flow-init.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ git-flow-init - Initialize git-flow in a repository

## SYNOPSIS

**git-flow init** [**-f**|**--force**] [**--preset**=*preset*] [**--custom**] [**--defaults**] [**--local**|**--global**|**--system**|**--file**=*path*] [*options*]
**git-flow init** [**-f**|**--force**] [**--preset**=*preset*] [**--custom**] [**--defaults**] [**--local**|**--global**|**--system**|**--shared**|**--file**=*path*] [*options*]

## DESCRIPTION

Expand Down Expand Up @@ -52,6 +52,9 @@ These options control where git-flow configuration is stored. Only one scope opt
**--system**
: Read and write configuration in the system-wide **/etc/gitconfig** file. Typically requires administrator privileges.

**--shared**
: Write configuration to a **.gitflow** file in the repository root instead of **.git/config**. The file is a standard Git config file that can be committed to the repository and shared with the team. After writing, an `include.path = ../.gitflow` entry is automatically added to the local **.git/config** so git-flow reads from it immediately. Any teammate who clones the repository benefits from auto-detection: the first git-flow command they run will detect **.gitflow** and wire the include automatically — no manual `git flow init` required. Mutually exclusive with **--local**, **--global**, **--system**, and **--file**.

**--file**=*path*
: Read and write configuration in the specified file. The parent directory must exist and be writable. Paths may be absolute or relative to the current working directory. Useful for managing shared configuration files.

Expand Down Expand Up @@ -217,6 +220,13 @@ Initialize with local scope (repository-specific):
git flow init --defaults --local
```

Initialize shared configuration for team use:
```bash
git flow init --preset=classic --shared
git add .gitflow
git commit -m "chore: add shared git-flow configuration"
```

Initialize with configuration file:
```bash
git flow init --defaults --file=/path/to/custom-gitflow.config
Expand Down Expand Up @@ -272,6 +282,7 @@ By default, git-flow stores configuration in the repository's **.git/config** fi
- Existing branches are preserved during initialization
- When initializing a repository with no existing commits, **git-flow init** creates an empty initial commit to enable branch creation. No files are added to the working directory
- Compatible with repositories previously initialized with git-flow-avh
- Configuration scope options (**--local**, **--global**, **--system**, **--file**) only affect the **init** command. All other git-flow commands (start, finish, update, etc.) always read from merged config using Git's standard precedence (local > global > system)
- Configuration scope options (**--local**, **--global**, **--system**, **--shared**, **--file**) only affect the **init** command. All other git-flow commands (start, finish, update, etc.) always read from merged config using Git's standard precedence (local > global > system > include files)
- When checking initialization status without an explicit scope flag, git-flow checks merged config and reports which scope the configuration was found in
- When initialized via global or system config, attempting to initialize again without a scope flag will suggest using **--local** to create repository-specific config
- When **--shared** is used, the resulting **.gitflow** file should be committed to the repository so teammates can benefit from it. Upon cloning, the first git-flow command automatically detects **.gitflow** and wires the include — no manual initialization needed
13 changes: 9 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,19 +331,24 @@ type InitializedStatus struct {
}

// IsGitFlowNextInitializedWithScope checks if git-flow-next is initialized at a specific scope.
// - ConfigScopeDefault: checks merged config, returns the scope where config was found
// - ConfigScopeDefault: checks merged config (includes include.path files), returns the scope where config was found
// - Specific scope: checks only that scope
func IsGitFlowNextInitializedWithScope(scope git.ConfigScope, filePath string) (InitializedStatus, error) {
if scope == git.ConfigScopeDefault {
// Check scopes in order: local > global > system
// Return the first scope where gitflow.version is found
// First, check the merged config (this picks up config from include.path files like .gitflow)
mergedVersion, mergedErr := git.GetConfigWithScope("gitflow.version", git.ConfigScopeDefault, "")
if mergedErr != nil || mergedVersion == "" {
return InitializedStatus{Initialized: false}, nil
}
// Config found via merged read — determine which explicit scope it lives in for messaging
for _, checkScope := range []git.ConfigScope{git.ConfigScopeLocal, git.ConfigScopeGlobal, git.ConfigScopeSystem} {
version, err := git.GetConfigWithScope("gitflow.version", checkScope, "")
if err == nil && version != "" {
return InitializedStatus{Initialized: true, SourceScope: checkScope}, nil
}
}
return InitializedStatus{Initialized: false}, nil
// Config found only via an included file (e.g., .gitflow via include.path) — treat as local
return InitializedStatus{Initialized: true, SourceScope: git.ConfigScopeLocal}, nil
}

// Check only the specified scope
Expand Down
34 changes: 34 additions & 0 deletions internal/git/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,40 @@ func GetConfigAllValuesInDir(dir, key string) ([]string, error) {
return values, nil
}

// GetLocalConfigAllValues gets all values for a multi-value key from the local git config only.
// Useful for inspecting multi-value keys like include.path without inheriting global/system values.
func GetLocalConfigAllValues(key string) ([]string, error) {
cmd := exec.Command("git", "config", "--local", "--get-all", key)
output, err := cmd.Output()
if err != nil {
// exit status 1 means no values found — not an error
if strings.Contains(err.Error(), "exit status 1") {
return []string{}, nil
}
return nil, fmt.Errorf("failed to get local git config %s: %w", key, err)
}
var values []string
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
for _, line := range lines {
if line != "" {
values = append(values, line)
}
}
return values, nil
}

// AddLocalConfigValue adds a value to a multi-value key in the local git config.
// Unlike SetConfig which replaces the value, this appends a new entry.
// Useful for multi-value keys like include.path.
func AddLocalConfigValue(key, value string) error {
cmd := exec.Command("git", "config", "--local", "--add", key, value)
_, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to add local git config %s: %w", key, err)
}
return nil
}

// GetConfigInDir gets a Git config value in the specified directory
func GetConfigInDir(dir, key string) (string, error) {
cmd := exec.Command("git", "config", "--get", key)
Expand Down
11 changes: 11 additions & 0 deletions internal/git/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ func IsGitRepo() bool {
return err == nil
}

// GetRepoTopLevel returns the top-level directory of the current Git repository.
// Returns an error if not in a Git repository or the command fails.
func GetRepoTopLevel() (string, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get repository top level: %w", err)
}
return strings.TrimSpace(string(output)), nil
}

// GetGitDir returns the path to the git directory for the current repository.
// For regular repositories, this returns ".git".
// For worktrees, this returns the actual git directory path (e.g., "/repo/.git/worktrees/work1").
Expand Down