Skip to content
This repository was archived by the owner on Sep 11, 2020. It is now read-only.

Add merge base command #1096

Merged
merged 2 commits into from
Jun 14, 2019
Merged
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
2 changes: 1 addition & 1 deletion COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ is supported by go-git.
| for-each-ref | ✔ |
| hash-object | ✔ |
| ls-files | ✔ |
| merge-base | |
| merge-base | ✔ | Calculates the merge-base only between two commits, and supports `--independent` and `--is-ancestor` modifiers; Does not support `--fork-point` nor `--octopus` modifiers. |
| read-tree | |
| rev-list | ✔ |
| rev-parse | |
Expand Down
13 changes: 7 additions & 6 deletions _examples/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var args = map[string][]string{
"tag": {cloneRepository(defaultURL, tempFolder())},
"pull": {createRepositoryWithRemote(tempFolder(), defaultURL)},
"ls": {cloneRepository(defaultURL, tempFolder()), "HEAD", "vendor"},
"merge_base": {cloneRepository(defaultURL, tempFolder()), "--is-ancestor", "HEAD~3", "HEAD^"},
}

var ignored = map[string]bool{}
Expand All @@ -50,14 +51,15 @@ func TestExamples(t *testing.T) {
}

for _, example := range examples {
_, name := filepath.Split(filepath.Dir(example))
dir := filepath.Dir(example)
_, name := filepath.Split(dir)

if ignored[name] {
continue
}

t.Run(name, func(t *testing.T) {
testExample(t, name, example)
testExample(t, name, dir)
})
}
}
Expand Down Expand Up @@ -135,10 +137,9 @@ func addRemote(local, remote string) {
CheckIfError(err)
}

func testExample(t *testing.T, name, example string) {
cmd := exec.Command("go", append([]string{
"run", filepath.Join(example),
}, args[name]...)...)
func testExample(t *testing.T, name, dir string) {
arguments := append([]string{"run", dir}, args[name]...)
cmd := exec.Command("go", arguments...)

cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Expand Down
63 changes: 63 additions & 0 deletions _examples/merge_base/help-long.msg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package main

const helpLongMsg = `
NAME:
%_COMMAND_NAME_% - Lists the best common ancestors of the two passed commit revisions

SYNOPSIS:
usage: %_COMMAND_NAME_% <path> <commitRev> <commitRev>
or: %_COMMAND_NAME_% <path> --independent <commitRev>...
or: %_COMMAND_NAME_% <path> --is-ancestor <commitRev> <commitRev>

params:
<path> Path to the git repository
<commitRev> Git revision as supported by go-git

DESCRIPTION:
%_COMMAND_NAME_% finds the best common ancestor(s) between two commits. One common ancestor is better than another common ancestor if the latter is an ancestor of the former.
A common ancestor that does not have any better common ancestor is a best common ancestor, i.e. a merge base. Note that there can be more than one merge base for a pair of commits.
Commits that does not share a common history has no common ancestors.

OPTIONS:
As the most common special case, specifying only two commits on the command line means computing the merge base between the given two commits.
If there is no shared history between the passed commits, there won't be a merge-base, and the command will exit with status 1.

--independent
List the subgroup from the passed commits, that cannot be reached from any other of the passed ones. In other words, it prints a minimal subset of the supplied commits with the same ancestors.

--is-ancestor
Check if the first commit is an ancestor of the second one, and exit with status 0 if true, or with status 1 if not. Errors are signaled by a non-zero status that is not 1.

DISCUSSION:
Given two commits A and B, %_COMMAND_NAME_% A B will output a commit which is the best common ancestor of both, what means that is reachable from both A and B through the parent relationship.

For example, with this topology:

o---o---o---o---B
/ /
---3---2---o---1---o---A

the merge base between A and B is 1.

With the given topology 2 and 3 are also common ancestors of A and B, but they are not the best ones because they can be also reached from 1.

When the history involves cross-cross merges, there can be more than one best common ancestor for two commits. For example, with this topology:

---1---o---A
\ /
X
/ \
---2---o---o---B

When the history involves feature branches depending on other feature branches there can be also more than one common ancestor. For example:


o---o---o
/ \
1---o---A \
/ / \
---o---o---2---o---o---B

In both examples, both 1 and 2 are merge-bases of A and B for each situation.
Neither one is better than the other (both are best merge bases) because 1 cannot be reached from 2, nor the opposite.
`
61 changes: 61 additions & 0 deletions _examples/merge_base/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"fmt"
"os"
"strings"

"gopkg.in/src-d/go-git.v4/plumbing/object"
)

func checkIfError(err error, code exitCode, mainReason string, v ...interface{}) {
if err == nil {
return
}

printErr(wrappErr(err, mainReason, v...))
os.Exit(int(code))
}

func helpAndExit(s string, helpMsg string, code exitCode) {
if code == exitCodeSuccess {
printMsg("%s", s)
} else {
printErr(fmt.Errorf(s))
}

fmt.Println(strings.Replace(helpMsg, "%_COMMAND_NAME_%", os.Args[0], -1))

os.Exit(int(code))
}

func printErr(err error) {
fmt.Printf("\x1b[31;1m%s\x1b[0m\n", fmt.Sprintf("error: %s", err))
}

func printMsg(format string, args ...interface{}) {
fmt.Printf("%s\n", fmt.Sprintf(format, args...))
}

func printCommits(commits []*object.Commit) {
for _, commit := range commits {
if os.Getenv("LOG_LEVEL") == "verbose" {
fmt.Printf(
"\x1b[36;1m%s \x1b[90;21m%s\x1b[0m %s\n",
commit.Hash.String()[:7],
commit.Hash.String(),
strings.Split(commit.Message, "\n")[0],
)
} else {
fmt.Println(commit.Hash.String())
}
}
}

func wrappErr(err error, s string, v ...interface{}) error {
if err != nil {
return fmt.Errorf("%s\n %s", fmt.Sprintf(s, v...), err)
}

return nil
}
124 changes: 124 additions & 0 deletions _examples/merge_base/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package main

import (
"os"

"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/object"
)

type exitCode int

const (
exitCodeSuccess exitCode = iota
exitCodeNotFound
exitCodeWrongSyntax
exitCodeCouldNotOpenRepository
exitCodeCouldNotParseRevision
exitCodeUnexpected

cmdDesc = "Returns the merge-base between two commits:"

helpShortMsg = `
usage: %_COMMAND_NAME_% <path> <commitRev> <commitRev>
or: %_COMMAND_NAME_% <path> --independent <commitRev>...
or: %_COMMAND_NAME_% <path> --is-ancestor <commitRev> <commitRev>
or: %_COMMAND_NAME_% --help

params:
<path> path to the git repository
<commitRev> git revision as supported by go-git

options:
(no options) lists the best common ancestors of the two passed commits
--independent list commits not reachable from the others
--is-ancestor is the first one ancestor of the other?
--help show the full help message of %_COMMAND_NAME_%
`
)

// Command that mimics `git merge-base --all <baseRev> <headRev>`
// Command that mimics `git merge-base --is-ancestor <baseRev> <headRev>`
// Command that mimics `git merge-base --independent <commitRev>...`
func main() {
if len(os.Args) == 1 {
helpAndExit("Returns the merge-base between two commits:", helpShortMsg, exitCodeSuccess)
}

if os.Args[1] == "--help" || os.Args[1] == "-h" {
helpAndExit("Returns the merge-base between two commits:", helpLongMsg, exitCodeSuccess)
}

if len(os.Args) < 4 {
helpAndExit("Wrong syntax", helpShortMsg, exitCodeWrongSyntax)
}

path := os.Args[1]

var modeIndependent, modeAncestor bool
var commitRevs []string
var res []*object.Commit

switch os.Args[2] {
case "--independent":
modeIndependent = true
commitRevs = os.Args[3:]
case "--is-ancestor":
modeAncestor = true
commitRevs = os.Args[3:]
if len(commitRevs) != 2 {
helpAndExit("Wrong syntax", helpShortMsg, exitCodeWrongSyntax)
}
default:
commitRevs = os.Args[2:]
if len(commitRevs) != 2 {
helpAndExit("Wrong syntax", helpShortMsg, exitCodeWrongSyntax)
}
}

// Open a git repository from current directory
repo, err := git.PlainOpen(path)
checkIfError(err, exitCodeCouldNotOpenRepository, "not in a git repository")

// Get the hashes of the passed revisions
var hashes []*plumbing.Hash
for _, rev := range commitRevs {
hash, err := repo.ResolveRevision(plumbing.Revision(rev))
checkIfError(err, exitCodeCouldNotParseRevision, "could not parse revision '%s'", rev)
hashes = append(hashes, hash)
}

// Get the commits identified by the passed hashes
var commits []*object.Commit
for _, hash := range hashes {
commit, err := repo.CommitObject(*hash)
checkIfError(err, exitCodeUnexpected, "could not find commit '%s'", hash.String())
commits = append(commits, commit)
}

if modeAncestor {
isAncestor, err := commits[0].IsAncestor(commits[1])
checkIfError(err, exitCodeUnexpected, "could not traverse the repository history")

if !isAncestor {
os.Exit(int(exitCodeNotFound))
}

os.Exit(int(exitCodeSuccess))
}

if modeIndependent {
res, err = object.Independents(commits)
checkIfError(err, exitCodeUnexpected, "could not traverse the repository history")
} else {
res, err = commits[0].MergeBase(commits[1])
checkIfError(err, exitCodeUnexpected, "could not traverse the repository history")

if len(res) == 0 {
os.Exit(int(exitCodeNotFound))
}
}

printCommits(res)
}