diff --git a/config/config.go b/config/config.go index 1bade56..120df1d 100644 --- a/config/config.go +++ b/config/config.go @@ -3,14 +3,16 @@ package config import ( "errors" "flag" - "github.com/lesfurets/git-octopus/git" "strconv" "strings" + + "github.com/lesfurets/git-octopus/git" ) type OctopusConfig struct { PrintVersion bool DoCommit bool + RecursiveMode bool ChunkSize int ExcludedPatterns []string Patterns []string @@ -29,7 +31,7 @@ func (e *excluded_patterns) Set(value string) error { func GetOctopusConfig(repo *git.Repository, args []string) (*OctopusConfig, error) { - var printVersion, noCommitArg, commitArg bool + var printVersion, noCommitArg, commitArg, recursiveArg bool var chunkSizeArg int var excludedPatternsArg excluded_patterns @@ -37,6 +39,7 @@ func GetOctopusConfig(repo *git.Repository, args []string) (*OctopusConfig, erro commandLine.BoolVar(&printVersion, "v", false, "prints the version of git-octopus.") commandLine.BoolVar(&noCommitArg, "n", false, "leaves the repository back to HEAD.") commandLine.BoolVar(&commitArg, "c", false, "Commit the resulting merge in the current branch.") + commandLine.BoolVar(&recursiveArg, "r", false, "merge using a traditional recursive merge (implies -s 1)") commandLine.IntVar(&chunkSizeArg, "s", 0, "do the octopus by chunk of n branches.") commandLine.Var(&excludedPatternsArg, "e", "exclude branches matching the pattern.") @@ -90,6 +93,7 @@ func GetOctopusConfig(repo *git.Repository, args []string) (*OctopusConfig, erro return &OctopusConfig{ PrintVersion: printVersion, DoCommit: configCommit, + RecursiveMode: recursiveArg, ChunkSize: chunkSizeArg, ExcludedPatterns: excludedPatterns, Patterns: patterns, diff --git a/config/config_test.go b/config/config_test.go index 5e700ad..77379e4 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,10 +1,11 @@ package config import ( + "testing" + "github.com/lesfurets/git-octopus/git" "github.com/lesfurets/git-octopus/test" "github.com/stretchr/testify/assert" - "testing" ) func createTestRepo() *git.Repository { @@ -146,3 +147,24 @@ func TestPatterns(t *testing.T) { assert.Equal(t, []string{"arg1", "arg2"}, octopusConfig.Patterns) assert.Nil(t, err) } + +func TestRecurisveMode(t *testing.T) { + repo := createTestRepo() + defer test.Cleanup(repo) + + // GIVEN No option + // WHEN + octopusConfig, err := GetOctopusConfig(repo, nil) + + // THEN RecursiveMode should be false + assert.False(t, octopusConfig.RecursiveMode) + assert.Nil(t, err) + + // GIVEN option -r + // WHEN + octopusConfig, err = GetOctopusConfig(repo, []string{"-r"}) + + // THEN RecursiveMode should be true + assert.True(t, octopusConfig.RecursiveMode) + assert.Nil(t, err) +} diff --git a/run/basic_chunked_test.go b/run/basic_chunked_test.go new file mode 100644 index 0000000..107906a --- /dev/null +++ b/run/basic_chunked_test.go @@ -0,0 +1,74 @@ +package run + +import ( + "os" + "path/filepath" + "testing" + + "github.com/lesfurets/git-octopus/test" + "github.com/stretchr/testify/assert" +) + +// Basic merge of 3 branches with chunk of 2. Asserts the resulting tree and the merge commit +func TestOctopus3BranchesChunked(t *testing.T) { + context, _ := CreateTestContext() + repo := context.Repo + defer test.Cleanup(repo) + + // Create and commit file foo1 in branch1 + repo.Git("checkout", "-b", "branch1") + writeFile(repo, "foo1", "First line") + repo.Git("add", "foo1") + repo.Git("commit", "-m\"\"") + + // Create and commit file foo2 in branch2 + repo.Git("checkout", "-b", "branch2", "master") + writeFile(repo, "foo2", "First line") + repo.Git("add", "foo2") + repo.Git("commit", "-m\"\"") + + // Create and commit file foo3 in branch3 + repo.Git("checkout", "-b", "branch3", "master") + writeFile(repo, "foo3", "First line") + repo.Git("add", "foo3") + repo.Git("commit", "-m\"\"") + + // Merge the 3 branches in a new octopus branch + repo.Git("checkout", "-b", "octopus", "master") + + err := Run(context, "-s=2", "branch*") + assert.Nil(t, err) + + // The working tree should have the 3 files and status should be clean + _, err = os.Open(filepath.Join(repo.Path, "foo1")) + assert.Nil(t, err) + _, err = os.Open(filepath.Join(repo.Path, "foo2")) + assert.Nil(t, err) + _, err = os.Open(filepath.Join(repo.Path, "foo3")) + assert.Nil(t, err) + + status, _ := repo.Git("status", "--porcelain") + assert.Empty(t, status) + + // octopus branch should contain the 3 branches + _, err = repo.Git("merge-base", "--is-ancestor", "branch1", "octopus") + assert.Nil(t, err) + _, err = repo.Git("merge-base", "--is-ancestor", "branch2", "octopus") + assert.Nil(t, err) + _, err = repo.Git("merge-base", "--is-ancestor", "branch3", "octopus") + assert.Nil(t, err) + + // // Assert the commit message + commitMessage1, _ := repo.Git("show", "--pretty=format:%B") // gets the commit body only + assert.Contains(t, commitMessage1, + "Merged branches:\n"+ + "refs/heads/branch3\n"+ + "\nCommit created by git-octopus "+VERSION+".") + + commitMessage2, _ := repo.Git("show", "--pretty=format:%B", "HEAD^") // gets the commit body only + assert.Contains(t, commitMessage2, + "Merged branches:\n"+ + "refs/heads/branch1\n"+ + "refs/heads/branch2\n"+ + "\nCommit created by git-octopus "+VERSION+".") +} diff --git a/run/recursive_mode_test.go b/run/recursive_mode_test.go new file mode 100644 index 0000000..ad6c9bc --- /dev/null +++ b/run/recursive_mode_test.go @@ -0,0 +1,148 @@ +package run + +import ( + "os" + "path/filepath" + "testing" + + "github.com/lesfurets/git-octopus/test" + "github.com/stretchr/testify/assert" +) + +// Basic merge of 3 branches. Asserts the resulting tree and the merge commit +func TestOctopus3BranchesRecursive(t *testing.T) { + context, _ := CreateTestContext() + repo := context.Repo + defer test.Cleanup(repo) + + // Create and commit file foo1 in branch1 + repo.Git("checkout", "-b", "branch1") + writeFile(repo, "foo1", "First line") + repo.Git("add", "foo1") + repo.Git("commit", "-m\"\"") + + // Create and commit file foo2 in branch2 + repo.Git("checkout", "-b", "branch2", "master") + writeFile(repo, "foo2", "First line") + repo.Git("add", "foo2") + repo.Git("commit", "-m\"\"") + + // Create and commit file foo3 in branch3 + repo.Git("checkout", "-b", "branch3", "master") + writeFile(repo, "foo3", "First line") + repo.Git("add", "foo3") + repo.Git("commit", "-m\"\"") + + // Merge the 3 branches in a new octopus branch + repo.Git("checkout", "-b", "octopus", "master") + + err := Run(context, "-r", "branch*") + assert.Nil(t, err) + + // The working tree should have the 3 files and status should be clean + _, err = os.Open(filepath.Join(repo.Path, "foo1")) + assert.Nil(t, err) + _, err = os.Open(filepath.Join(repo.Path, "foo2")) + assert.Nil(t, err) + _, err = os.Open(filepath.Join(repo.Path, "foo3")) + assert.Nil(t, err) + + status, _ := repo.Git("status", "--porcelain") + assert.Empty(t, status) + + // octopus branch should contain the 3 branches + _, err = repo.Git("merge-base", "--is-ancestor", "branch1", "octopus") + assert.Nil(t, err) + _, err = repo.Git("merge-base", "--is-ancestor", "branch2", "octopus") + assert.Nil(t, err) + _, err = repo.Git("merge-base", "--is-ancestor", "branch3", "octopus") + assert.Nil(t, err) +} + +func TestOctopus2BranchesRecursiveUnresolvedConflict(t *testing.T) { + context, _ := CreateTestContext() + repo := context.Repo + defer test.Cleanup(repo) + + // Create and commit file foo1 in branch1 + repo.Git("checkout", "-b", "branch1") + writeFile(repo, "foo1", "First line from b1") + repo.Git("add", "foo1") + repo.Git("commit", "-m\"\"") + + // Create and commit file foo2 in branch2 + repo.Git("checkout", "-b", "branch2", "master") + writeFile(repo, "foo1", "First line from b2") + repo.Git("add", "foo1") + repo.Git("commit", "-m\"\"") + + // Merge the 3 branches in a new octopus branch + repo.Git("checkout", "-b", "octopus", "master") + + err := Run(context, "-r", "branch*") + assert.EqualError(t, err, "Unresolved merge conflict:\nAA foo1", "There should be a conflict") +} + +func TestOctopus2BranchesRecursivePreRecordedConflict(t *testing.T) { + context, _ := CreateTestContext() + repo := context.Repo + defer test.Cleanup(repo) + + // Create and commit file foo1 in branch1 + repo.Git("checkout", "-b", "branch1") + writeFile(repo, "foo1", "First line from b1") + repo.Git("add", "foo1") + repo.Git("commit", "-m\"\"") + + // Create and commit file foo2 in branch2 + repo.Git("checkout", "-b", "branch2", "master") + writeFile(repo, "foo1", "First line from b2") + repo.Git("add", "foo1") + repo.Git("commit", "-m\"\"") + + repo.Git("checkout", "-b", "rereretrain", "master") + repo.Git("config", "--local", "rerere.enabled", "true") + repo.Git("merge", "branch1") + repo.Git("merge", "branch2") + writeFile(repo, "foo1", "First line from b1\nFirst line from b2") + repo.Git("add", "foo1") + repo.Git("commit", "--no-edit") + + // Merge the 3 branches in a new octopus branch + repo.Git("checkout", "-b", "octopus", "master") + + err := Run(context, "-r", "branch*") + assert.Nil(t, err) +} + +func TestOctopus2BranchesRecursiveFallbackPreRecordedConflict(t *testing.T) { + context, _ := CreateTestContext() + repo := context.Repo + defer test.Cleanup(repo) + + // Create and commit file foo1 in branch1 + repo.Git("checkout", "-b", "branch1") + writeFile(repo, "foo1", "First line from b1") + repo.Git("add", "foo1") + repo.Git("commit", "-m\"\"") + + // Create and commit file foo2 in branch2 + repo.Git("checkout", "-b", "branch2", "master") + writeFile(repo, "foo1", "First line from b2") + repo.Git("add", "foo1") + repo.Git("commit", "-m\"\"") + + repo.Git("checkout", "-b", "rereretrain", "master") + repo.Git("config", "--local", "rerere.enabled", "true") + repo.Git("merge", "branch1") + repo.Git("merge", "branch2") + writeFile(repo, "foo1", "First line from b1\nFirst line from b2") + repo.Git("add", "foo1") + repo.Git("commit", "--no-edit") + + // Merge the 3 branches in a new octopus branch + repo.Git("checkout", "-b", "octopus", "master") + + err := Run(context, "-r", "-s=2", "branch*") + assert.Nil(t, err) +} diff --git a/run/run.go b/run/run.go index 12a5d8f..4dc1fe0 100644 --- a/run/run.go +++ b/run/run.go @@ -1,11 +1,14 @@ package run import ( + "bufio" "bytes" "errors" + "log" + "strings" + "github.com/lesfurets/git-octopus/config" "github.com/lesfurets/git-octopus/git" - "log" ) type OctopusContext struct { @@ -46,16 +49,84 @@ func Run(context *OctopusContext, args ...string) error { return nil } - initialHeadCommit, _ := context.Repo.Git("rev-parse", "HEAD") - context.Logger.Println() - parents, err := mergeHeads(context, branchList) + initialHeadCommit, _ := context.Repo.Git("rev-parse", "HEAD") + + mergeStrategy := chooseMergeStrategy(octopusConfig) + err = mergeStrategy(context, octopusConfig, branchList) if !octopusConfig.DoCommit { context.Repo.Git("reset", "-q", "--hard", initialHeadCommit) } + context.Logger.Println() + + return err +} + +type strategy func(context *OctopusContext, octopusConfig *config.OctopusConfig, branchList []git.LsRemoteEntry) error + +func chooseMergeStrategy(octopusConfig *config.OctopusConfig) strategy { + chunkMode := octopusConfig.ChunkSize > 0 + if octopusConfig.RecursiveMode { + if chunkMode { + return chunckBranches(octopusWithRecursiveFallbackStrategy) + } + return recursiveStrategy + } + if chunkMode { + return chunckBranches(octopusStrategy) + } + return octopusStrategy +} + +func chunckBranches(mergeStrategy strategy) strategy { + return func(context *OctopusContext, octopusConfig *config.OctopusConfig, branchList []git.LsRemoteEntry) error { + var remaning []git.LsRemoteEntry = branchList + chunkSize := octopusConfig.ChunkSize + acc := 1 + lenBranchList := len(branchList) + context.Logger.Printf("Will merge %d branches by chunks of %d", lenBranchList, chunkSize) + for len(remaning) > 0 { + var current []git.LsRemoteEntry + if len(remaning) > chunkSize { + current, remaning = remaning[:chunkSize], remaning[chunkSize:] + } else { + current, remaning = remaning, nil + } + lcur := len(current) + context.Logger.Printf("Merging chunks %d to %d (out of %d)\n", acc, acc+lcur-1, lenBranchList) + acc += lcur + err := mergeStrategy(context, octopusConfig, current) + if err != nil { + return err + } + } + return nil + } +} + +func octopusWithRecursiveFallbackStrategy(context *OctopusContext, octopusConfig *config.OctopusConfig, + branchList []git.LsRemoteEntry) error { + + currentHeadCommit, _ := context.Repo.Git("rev-parse", "HEAD") + err := octopusStrategy(context, octopusConfig, branchList) + if err != nil { + if len(err.Error()) > 0 { + context.Logger.Println(err.Error()) + } + context.Logger.Printf("Octopus strategy failed for branches %v, fallback to one by one recursive merge\n", branchList) + context.Repo.Git("reset", "-q", "--hard", currentHeadCommit) + err = recursiveStrategy(context, octopusConfig, branchList) + } + return err +} + +func octopusStrategy(context *OctopusContext, octopusConfig *config.OctopusConfig, + branchList []git.LsRemoteEntry) error { + parents, err := mergeHeads(context, branchList) + if err != nil { return err } @@ -71,10 +142,54 @@ func Run(context *OctopusContext, args ...string) error { commit, _ := context.Repo.Git(args...) context.Repo.Git("update-ref", "HEAD", commit) } - return nil } +func recursiveStrategy(context *OctopusContext, octopusConfig *config.OctopusConfig, + branchList []git.LsRemoteEntry) error { + context.Logger.Println("Merging using recursive mode") + _, err := mergeRecursive(context, branchList) + + return err +} + +func mergeRecursive(context *OctopusContext, remotes []git.LsRemoteEntry) ([]string, error) { + head, _ := context.Repo.Git("rev-parse", "--verify", "-q", "HEAD") + mrc := []string{head} + for _, lsRemoteEntry := range remotes { + context.Logger.Println("Merging " + lsRemoteEntry.Ref) + log, _ := context.Repo.Git("merge", "--no-commit", "--rerere-autoupdate", lsRemoteEntry.Ref) + if len(log) > 0 { + context.Logger.Println(log) + } + + status, _ := context.Repo.Git("status", "--porcelain") + if isMergeStatusOk(context, status) { + context.Repo.Git("commit", "--no-edit") + mrc = append(mrc, lsRemoteEntry.Sha1) + } else { + return nil, errors.New("Unresolved merge conflict:\n" + status) + } + } + context.Repo.Git("commit", "-m", octopusCommitMessage(remotes), "--allow-empty") + return mrc, nil +} + +// Takes the output of git-ls-remote. Returns a map refsname => sha1 +func isMergeStatusOk(context *OctopusContext, status string) bool { + scanner := bufio.NewScanner(strings.NewReader(status)) + for scanner.Scan() { + split := strings.Fields(scanner.Text()) + + switch split[0] { + case "DD", "AU", "UD", "UA", "DU", "AA", "UU": + return false + } + } + + return true +} + // The logic of this function is copied directly from git-merge-octopus.sh func mergeHeads(context *OctopusContext, remotes []git.LsRemoteEntry) ([]string, error) { head, _ := context.Repo.Git("rev-parse", "--verify", "-q", "HEAD") @@ -113,7 +228,9 @@ func mergeHeads(context *OctopusContext, remotes []git.LsRemoteEntry) ([]string, context.Logger.Println("Trying simple merge with " + lsRemoteEntry.Ref) - _, err = context.Repo.Git("read-tree", "-u", "-m", "--aggressive", common, mrt, lsRemoteEntry.Sha1) + commonArray := strings.Split(common, "\n") + _, err = context.Repo.Git(append([]string{"read-tree", "-u", "-m", "--aggressive"}, + append(commonArray, mrt, lsRemoteEntry.Sha1)...)...) if err != nil { return nil, err