diff --git a/cmd/init.go b/cmd/init.go index bf12364..dc7138c 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -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() @@ -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 { @@ -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 @@ -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: @@ -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 { @@ -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: @@ -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 { @@ -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 } @@ -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 "" } diff --git a/cmd/root.go b/cmd/root.go index 44882bf..daf2346 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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", @@ -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 { diff --git a/docs/git-flow-init.1.md b/docs/git-flow-init.1.md index fbbf4e2..15b2965 100644 --- a/docs/git-flow-init.1.md +++ b/docs/git-flow-init.1.md @@ -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 @@ -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. @@ -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 @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 6e2c91e..c7511e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/git/config.go b/internal/git/config.go index 703960f..92722c1 100644 --- a/internal/git/config.go +++ b/internal/git/config.go @@ -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) diff --git a/internal/git/repo.go b/internal/git/repo.go index 95347a6..af3ff89 100644 --- a/internal/git/repo.go +++ b/internal/git/repo.go @@ -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").