diff options
-rw-r--r-- | COMPATIBILITY.md | 16 | ||||
-rw-r--r-- | _examples/README.md | 2 | ||||
-rw-r--r-- | options.go | 19 | ||||
-rw-r--r-- | plumbing/format/gitignore/dir.go | 4 | ||||
-rw-r--r-- | plumbing/object/commit_test.go | 2 | ||||
-rw-r--r-- | plumbing/object/commit_walker_path.go | 16 | ||||
-rw-r--r-- | plumbing/object/patch.go | 95 | ||||
-rw-r--r-- | plumbing/object/patch_test.go | 110 | ||||
-rw-r--r-- | plumbing/object/treenoder.go | 4 | ||||
-rw-r--r-- | remote.go | 2 | ||||
-rw-r--r-- | repository.go | 65 | ||||
-rw-r--r-- | repository_test.go | 113 |
12 files changed, 358 insertions, 90 deletions
diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index c1f280d..ff0c22c 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -27,14 +27,14 @@ compatibility status with go-git. ## Branching and merging -| Feature | Sub-feature | Status | Notes | Examples | -| ----------- | ----------- | ------ | --------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `branch` | | ✅ | | - [branch](_examples/branch/main.go) | -| `checkout` | | ✅ | Basic usages of checkout are supported. | - [checkout](_examples/checkout/main.go) | -| `merge` | | ❌ | | | -| `mergetool` | | ❌ | | | -| `stash` | | ❌ | | | -| `tag` | | ✅ | | - [tag](_examples/tag/main.go) <br/> - [tag create and push](_examples/tag-create-push/main.go) | +| Feature | Sub-feature | Status | Notes | Examples | +| ----------- | ----------- | ------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `branch` | | ✅ | | - [branch](_examples/branch/main.go) | +| `checkout` | | ✅ | Basic usages of checkout are supported. | - [checkout](_examples/checkout/main.go) | +| `merge` | | ⚠️ (partial) | Fast-forward only | | +| `mergetool` | | ❌ | | | +| `stash` | | ❌ | | | +| `tag` | | ✅ | | - [tag](_examples/tag/main.go) <br/> - [tag create and push](_examples/tag-create-push/main.go) | ## Sharing and updating projects diff --git a/_examples/README.md b/_examples/README.md index 8097f09..1e9ea6a 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -33,4 +33,4 @@ Here you can find a list of annotated _go-git_ examples: - [custom_http](custom_http/main.go) - Replacing the HTTP client using a custom one. - [clone with context](context/main.go) - Cloning a repository with graceful cancellation. - [storage](storage/README.md) - Implementing a custom storage system. -- [sha256](sha256/main.go) - Init and commiting repositories that use sha256 as object format. +- [sha256](sha256/main.go) - Init and committing repositories that use sha256 as object format. @@ -89,6 +89,25 @@ type CloneOptions struct { Shared bool } +// MergeOptions describes how a merge should be performed. +type MergeOptions struct { + // Strategy defines the merge strategy to be used. + Strategy MergeStrategy +} + +// MergeStrategy represents the different types of merge strategies. +type MergeStrategy int8 + +const ( + // FastForwardMerge represents a Git merge strategy where the current + // branch can be simply updated to point to the HEAD of the branch being + // merged. This is only possible if the history of the branch being merged + // is a linear descendant of the current branch, with no conflicting commits. + // + // This is the default option. + FastForwardMerge MergeStrategy = iota +) + // Validate validates the fields and sets the default values. func (o *CloneOptions) Validate() error { if o.URL == "" { diff --git a/plumbing/format/gitignore/dir.go b/plumbing/format/gitignore/dir.go index d8fb30c..aca5d0d 100644 --- a/plumbing/format/gitignore/dir.go +++ b/plumbing/format/gitignore/dir.go @@ -116,7 +116,7 @@ func loadPatterns(fs billy.Filesystem, path string) (ps []Pattern, err error) { return } -// LoadGlobalPatterns loads gitignore patterns from from the gitignore file +// LoadGlobalPatterns loads gitignore patterns from the gitignore file // declared in a user's ~/.gitconfig file. If the ~/.gitconfig file does not // exist the function will return nil. If the core.excludesfile property // is not declared, the function will return nil. If the file pointed to by @@ -132,7 +132,7 @@ func LoadGlobalPatterns(fs billy.Filesystem) (ps []Pattern, err error) { return loadPatterns(fs, fs.Join(home, gitconfigFile)) } -// LoadSystemPatterns loads gitignore patterns from from the gitignore file +// LoadSystemPatterns loads gitignore patterns from the gitignore file // declared in a system's /etc/gitconfig file. If the /etc/gitconfig file does // not exist the function will return nil. If the core.excludesfile property // is not declared, the function will return nil. If the file pointed to by diff --git a/plumbing/object/commit_test.go b/plumbing/object/commit_test.go index 6651ef8..a048926 100644 --- a/plumbing/object/commit_test.go +++ b/plumbing/object/commit_test.go @@ -455,7 +455,7 @@ func (s *SuiteCommit) TestStat(c *C) { c.Assert(fileStats[1].Name, Equals, "php/crappy.php") c.Assert(fileStats[1].Addition, Equals, 259) c.Assert(fileStats[1].Deletion, Equals, 0) - c.Assert(fileStats[1].String(), Equals, " php/crappy.php | 259 ++++++++++++++++++++++++++++++++++++++++++++++++++++\n") + c.Assert(fileStats[1].String(), Equals, " php/crappy.php | 259 +++++++++++++++++++++++++++++++++++++++++++++++++++++\n") } func (s *SuiteCommit) TestVerify(c *C) { diff --git a/plumbing/object/commit_walker_path.go b/plumbing/object/commit_walker_path.go index e054966..c1ec8ba 100644 --- a/plumbing/object/commit_walker_path.go +++ b/plumbing/object/commit_walker_path.go @@ -57,6 +57,8 @@ func (c *commitPathIter) Next() (*Commit, error) { } func (c *commitPathIter) getNextFileCommit() (*Commit, error) { + var parentTree, currentTree *Tree + for { // Parent-commit can be nil if the current-commit is the initial commit parentCommit, parentCommitErr := c.sourceIter.Next() @@ -68,13 +70,17 @@ func (c *commitPathIter) getNextFileCommit() (*Commit, error) { parentCommit = nil } - // Fetch the trees of the current and parent commits - currentTree, currTreeErr := c.currentCommit.Tree() - if currTreeErr != nil { - return nil, currTreeErr + if parentTree == nil { + var currTreeErr error + currentTree, currTreeErr = c.currentCommit.Tree() + if currTreeErr != nil { + return nil, currTreeErr + } + } else { + currentTree = parentTree + parentTree = nil } - var parentTree *Tree if parentCommit != nil { var parentTreeErr error parentTree, parentTreeErr = parentCommit.Tree() diff --git a/plumbing/object/patch.go b/plumbing/object/patch.go index dd8fef4..3c61f62 100644 --- a/plumbing/object/patch.go +++ b/plumbing/object/patch.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" "io" - "math" + "strconv" "strings" "github.com/go-git/go-git/v5/plumbing" @@ -234,69 +234,56 @@ func (fileStats FileStats) String() string { return printStat(fileStats) } +// printStat prints the stats of changes in content of files. +// Original implementation: https://github.com/git/git/blob/1a87c842ece327d03d08096395969aca5e0a6996/diff.c#L2615 +// Parts of the output: +// <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline> +// example: " main.go | 10 +++++++--- " func printStat(fileStats []FileStat) string { - padLength := float64(len(" ")) - newlineLength := float64(len("\n")) - separatorLength := float64(len("|")) - // Soft line length limit. The text length calculation below excludes - // length of the change number. Adding that would take it closer to 80, - // but probably not more than 80, until it's a huge number. - lineLength := 72.0 - - // Get the longest filename and longest total change. - var longestLength float64 - var longestTotalChange float64 - for _, fs := range fileStats { - if int(longestLength) < len(fs.Name) { - longestLength = float64(len(fs.Name)) - } - totalChange := fs.Addition + fs.Deletion - if int(longestTotalChange) < totalChange { - longestTotalChange = float64(totalChange) - } - } - - // Parts of the output: - // <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline> - // example: " main.go | 10 +++++++--- " - - // <pad><filename><pad> - leftTextLength := padLength + longestLength + padLength - - // <pad><number><pad><+++++/-----><newline> - // Excluding number length here. - rightTextLength := padLength + padLength + newlineLength + maxGraphWidth := uint(53) + maxNameLen := 0 + maxChangeLen := 0 - totalTextArea := leftTextLength + separatorLength + rightTextLength - heightOfHistogram := lineLength - totalTextArea + scaleLinear := func(it, width, max uint) uint { + if it == 0 || max == 0 { + return 0 + } - // Scale the histogram. - var scaleFactor float64 - if longestTotalChange > heightOfHistogram { - // Scale down to heightOfHistogram. - scaleFactor = longestTotalChange / heightOfHistogram - } else { - scaleFactor = 1.0 + return 1 + (it * (width - 1) / max) } - finalOutput := "" for _, fs := range fileStats { - addn := float64(fs.Addition) - deln := float64(fs.Deletion) - addc := int(math.Floor(addn/scaleFactor)) - delc := int(math.Floor(deln/scaleFactor)) - if addc < 0 { - addc = 0 + if len(fs.Name) > maxNameLen { + maxNameLen = len(fs.Name) } - if delc < 0 { - delc = 0 + + changes := strconv.Itoa(fs.Addition + fs.Deletion) + if len(changes) > maxChangeLen { + maxChangeLen = len(changes) } - adds := strings.Repeat("+", addc) - dels := strings.Repeat("-", delc) - finalOutput += fmt.Sprintf(" %s | %d %s%s\n", fs.Name, (fs.Addition + fs.Deletion), adds, dels) } - return finalOutput + result := "" + for _, fs := range fileStats { + add := uint(fs.Addition) + del := uint(fs.Deletion) + np := maxNameLen - len(fs.Name) + cp := maxChangeLen - len(strconv.Itoa(fs.Addition+fs.Deletion)) + + total := add + del + if total > maxGraphWidth { + add = scaleLinear(add, maxGraphWidth, total) + del = scaleLinear(del, maxGraphWidth, total) + } + + adds := strings.Repeat("+", int(add)) + dels := strings.Repeat("-", int(del)) + namePad := strings.Repeat(" ", np) + changePad := strings.Repeat(" ", cp) + + result += fmt.Sprintf(" %s%s | %s%d %s%s\n", fs.Name, namePad, changePad, total, adds, dels) + } + return result } func getFileStatsFromFilePatches(filePatches []fdiff.FilePatch) FileStats { diff --git a/plumbing/object/patch_test.go b/plumbing/object/patch_test.go index 2cff795..e0e63a5 100644 --- a/plumbing/object/patch_test.go +++ b/plumbing/object/patch_test.go @@ -45,3 +45,113 @@ func (s *PatchSuite) TestStatsWithSubmodules(c *C) { c.Assert(err, IsNil) c.Assert(p, NotNil) } + +func (s *PatchSuite) TestFileStatsString(c *C) { + testCases := []struct { + description string + input FileStats + expected string + }{ + + { + description: "no files changed", + input: []FileStat{}, + expected: "", + }, + { + description: "one file touched - no changes", + input: []FileStat{ + { + Name: "file1", + }, + }, + expected: " file1 | 0 \n", + }, + { + description: "one file changed", + input: []FileStat{ + { + Name: "file1", + Addition: 1, + }, + }, + expected: " file1 | 1 +\n", + }, + { + description: "one file changed with one addition and one deletion", + input: []FileStat{ + { + Name: ".github/workflows/git.yml", + Addition: 1, + Deletion: 1, + }, + }, + expected: " .github/workflows/git.yml | 2 +-\n", + }, + { + description: "two files changed", + input: []FileStat{ + { + Name: ".github/workflows/git.yml", + Addition: 1, + Deletion: 1, + }, + { + Name: "cli/go-git/go.mod", + Addition: 4, + Deletion: 4, + }, + }, + expected: " .github/workflows/git.yml | 2 +-\n cli/go-git/go.mod | 8 ++++----\n", + }, + { + description: "three files changed", + input: []FileStat{ + { + Name: ".github/workflows/git.yml", + Addition: 3, + Deletion: 3, + }, + { + Name: "worktree.go", + Addition: 107, + }, + { + Name: "worktree_test.go", + Addition: 75, + }, + }, + expected: " .github/workflows/git.yml | 6 +++---\n" + + " worktree.go | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++\n" + + " worktree_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++\n", + }, + { + description: "three files changed with deletions and additions", + input: []FileStat{ + { + Name: ".github/workflows/git.yml", + Addition: 3, + Deletion: 3, + }, + { + Name: "worktree.go", + Addition: 107, + Deletion: 217, + }, + { + Name: "worktree_test.go", + Addition: 75, + Deletion: 275, + }, + }, + expected: " .github/workflows/git.yml | 6 +++---\n" + + " worktree.go | 324 ++++++++++++++++++-----------------------------------\n" + + " worktree_test.go | 350 ++++++++++++-----------------------------------------\n", + }, + } + + for _, tc := range testCases { + c.Log("Executing test cases:", tc.description) + c.Assert(printStat(tc.input), Equals, tc.expected) + } +} diff --git a/plumbing/object/treenoder.go b/plumbing/object/treenoder.go index 6e7b334..2adb645 100644 --- a/plumbing/object/treenoder.go +++ b/plumbing/object/treenoder.go @@ -88,7 +88,9 @@ func (t *treeNoder) Children() ([]noder.Noder, error) { } } - return transformChildren(parent) + var err error + t.children, err = transformChildren(parent) + return t.children, err } // Returns the children of a tree as treenoders. @@ -1128,7 +1128,7 @@ func isFastForward(s storer.EncodedObjectStorer, old, new plumbing.Hash, earlies } found := false - // stop iterating at the earlist shallow commit, ignoring its parents + // stop iterating at the earliest shallow commit, ignoring its parents // note: when pull depth is smaller than the number of new changes on the remote, this fails due to missing parents. // as far as i can tell, without the commits in-between the shallow pull and the earliest shallow, there's no // real way of telling whether it will be a fast-forward merge. diff --git a/repository.go b/repository.go index 1524a69..a57c714 100644 --- a/repository.go +++ b/repository.go @@ -51,19 +51,21 @@ var ( // ErrFetching is returned when the packfile could not be downloaded ErrFetching = errors.New("unable to fetch packfile") - ErrInvalidReference = errors.New("invalid reference, should be a tag or a branch") - ErrRepositoryNotExists = errors.New("repository does not exist") - ErrRepositoryIncomplete = errors.New("repository's commondir path does not exist") - ErrRepositoryAlreadyExists = errors.New("repository already exists") - ErrRemoteNotFound = errors.New("remote not found") - ErrRemoteExists = errors.New("remote already exists") - ErrAnonymousRemoteName = errors.New("anonymous remote name must be 'anonymous'") - ErrWorktreeNotProvided = errors.New("worktree should be provided") - ErrIsBareRepository = errors.New("worktree not available in a bare repository") - ErrUnableToResolveCommit = errors.New("unable to resolve commit") - ErrPackedObjectsNotSupported = errors.New("packed objects not supported") - ErrSHA256NotSupported = errors.New("go-git was not compiled with SHA256 support") - ErrAlternatePathNotSupported = errors.New("alternate path must use the file scheme") + ErrInvalidReference = errors.New("invalid reference, should be a tag or a branch") + ErrRepositoryNotExists = errors.New("repository does not exist") + ErrRepositoryIncomplete = errors.New("repository's commondir path does not exist") + ErrRepositoryAlreadyExists = errors.New("repository already exists") + ErrRemoteNotFound = errors.New("remote not found") + ErrRemoteExists = errors.New("remote already exists") + ErrAnonymousRemoteName = errors.New("anonymous remote name must be 'anonymous'") + ErrWorktreeNotProvided = errors.New("worktree should be provided") + ErrIsBareRepository = errors.New("worktree not available in a bare repository") + ErrUnableToResolveCommit = errors.New("unable to resolve commit") + ErrPackedObjectsNotSupported = errors.New("packed objects not supported") + ErrSHA256NotSupported = errors.New("go-git was not compiled with SHA256 support") + ErrAlternatePathNotSupported = errors.New("alternate path must use the file scheme") + ErrUnsupportedMergeStrategy = errors.New("unsupported merge strategy") + ErrFastForwardMergeNotPossible = errors.New("not possible to fast-forward merge changes") ) // Repository represents a git repository @@ -1769,8 +1771,43 @@ func (r *Repository) RepackObjects(cfg *RepackConfig) (err error) { return nil } +// Merge merges the reference branch into the current branch. +// +// If the merge is not possible (or supported) returns an error without changing +// the HEAD for the current branch. Possible errors include: +// - The merge strategy is not supported. +// - The specific strategy cannot be used (e.g. using FastForwardMerge when one is not possible). +func (r *Repository) Merge(ref plumbing.Reference, opts MergeOptions) error { + if opts.Strategy != FastForwardMerge { + return ErrUnsupportedMergeStrategy + } + + // Ignore error as not having a shallow list is optional here. + shallowList, _ := r.Storer.Shallow() + var earliestShallow *plumbing.Hash + if len(shallowList) > 0 { + earliestShallow = &shallowList[0] + } + + head, err := r.Head() + if err != nil { + return err + } + + ff, err := isFastForward(r.Storer, head.Hash(), ref.Hash(), earliestShallow) + if err != nil { + return err + } + + if !ff { + return ErrFastForwardMergeNotPossible + } + + return r.Storer.SetReference(plumbing.NewHashReference(head.Name(), ref.Hash())) +} + // createNewObjectPack is a helper for RepackObjects taking care -// of creating a new pack. It is used so the the PackfileWriter +// of creating a new pack. It is used so the PackfileWriter // deferred close has the right scope. func (r *Repository) createNewObjectPack(cfg *RepackConfig) (h plumbing.Hash, err error) { ow := newObjectWalker(r.Storer) diff --git a/repository_test.go b/repository_test.go index 51df845..b211f8c 100644 --- a/repository_test.go +++ b/repository_test.go @@ -82,7 +82,7 @@ func (s *RepositorySuite) TestInitWithInvalidDefaultBranch(c *C) { c.Assert(err, NotNil) } -func createCommit(c *C, r *Repository) { +func createCommit(c *C, r *Repository) plumbing.Hash { // Create a commit so there is a HEAD to check wt, err := r.Worktree() c.Assert(err, IsNil) @@ -101,13 +101,14 @@ func createCommit(c *C, r *Repository) { Email: "go-git@fake.local", When: time.Now(), } - _, err = wt.Commit("test commit message", &CommitOptions{ + + h, err := wt.Commit("test commit message", &CommitOptions{ All: true, Author: &author, Committer: &author, }) c.Assert(err, IsNil) - + return h } func (s *RepositorySuite) TestInitNonStandardDotGit(c *C) { @@ -439,6 +440,112 @@ func (s *RepositorySuite) TestCreateBranchAndBranch(c *C) { c.Assert(branch.Merge, Equals, testBranch.Merge) } +func (s *RepositorySuite) TestMergeFF(c *C) { + r, err := Init(memory.NewStorage(), memfs.New()) + c.Assert(err, IsNil) + c.Assert(r, NotNil) + + createCommit(c, r) + createCommit(c, r) + createCommit(c, r) + lastCommit := createCommit(c, r) + + wt, err := r.Worktree() + c.Assert(err, IsNil) + + targetBranch := plumbing.NewBranchReferenceName("foo") + err = wt.Checkout(&CheckoutOptions{ + Hash: lastCommit, + Create: true, + Branch: targetBranch, + }) + c.Assert(err, IsNil) + + createCommit(c, r) + fooHash := createCommit(c, r) + + // Checkout the master branch so that we can try to merge foo into it. + err = wt.Checkout(&CheckoutOptions{ + Branch: plumbing.Master, + }) + c.Assert(err, IsNil) + + head, err := r.Head() + c.Assert(err, IsNil) + c.Assert(head.Hash(), Equals, lastCommit) + + targetRef := plumbing.NewHashReference(targetBranch, fooHash) + c.Assert(targetRef, NotNil) + + err = r.Merge(*targetRef, MergeOptions{ + Strategy: FastForwardMerge, + }) + c.Assert(err, IsNil) + + head, err = r.Head() + c.Assert(err, IsNil) + c.Assert(head.Hash(), Equals, fooHash) +} + +func (s *RepositorySuite) TestMergeFF_Invalid(c *C) { + r, err := Init(memory.NewStorage(), memfs.New()) + c.Assert(err, IsNil) + c.Assert(r, NotNil) + + // Keep track of the first commit, which will be the + // reference to create the target branch so that we + // can simulate a non-ff merge. + firstCommit := createCommit(c, r) + createCommit(c, r) + createCommit(c, r) + lastCommit := createCommit(c, r) + + wt, err := r.Worktree() + c.Assert(err, IsNil) + + targetBranch := plumbing.NewBranchReferenceName("foo") + err = wt.Checkout(&CheckoutOptions{ + Hash: firstCommit, + Create: true, + Branch: targetBranch, + }) + + c.Assert(err, IsNil) + + createCommit(c, r) + h := createCommit(c, r) + + // Checkout the master branch so that we can try to merge foo into it. + err = wt.Checkout(&CheckoutOptions{ + Branch: plumbing.Master, + }) + c.Assert(err, IsNil) + + head, err := r.Head() + c.Assert(err, IsNil) + c.Assert(head.Hash(), Equals, lastCommit) + + targetRef := plumbing.NewHashReference(targetBranch, h) + c.Assert(targetRef, NotNil) + + err = r.Merge(*targetRef, MergeOptions{ + Strategy: MergeStrategy(10), + }) + c.Assert(err, Equals, ErrUnsupportedMergeStrategy) + + // Failed merge operations must not change HEAD. + head, err = r.Head() + c.Assert(err, IsNil) + c.Assert(head.Hash(), Equals, lastCommit) + + err = r.Merge(*targetRef, MergeOptions{}) + c.Assert(err, Equals, ErrFastForwardMergeNotPossible) + + head, err = r.Head() + c.Assert(err, IsNil) + c.Assert(head.Hash(), Equals, lastCommit) +} + func (s *RepositorySuite) TestCreateBranchUnmarshal(c *C) { r, _ := Init(memory.NewStorage(), nil) |