go-git: Correct way to create a local branch, emulating behavior of "git branch <branchname>"?

5k views Asked by At

As the title suggests, I'm trying to figure out how to create a local branch using go-git in a way that gives the same result as the Git CLI command git branch <branchname>.

As far as I've been able to tell, git branch <branchname> (without an explicit <start-point> argument) does two things:

  1. Creates .git/refs/heads/<branchname> to point to the current HEAD commit
  2. Creates .git/logs/refs/heads/<branchname> with a single line recording the creation of the branch.

It may do more, but these two things I know it does for sure. (If you know something more that it does, please share!)

Most of what follows documents my journey of discovery as I researched my options, and I think I might now have a handle on #1 above. For #2, though, I am starting to think I may be SOL, at least using go-git.

First Thought: Repository.CreateBranch

My initial naive thought was to just call Repository.CreateBranch, and there's an answer to a similar SO question ("How to checkout a new local branch using go-git?") that would seem to lend credence to that idea. But once I started looking into the details, things got very confusing.

First, Repository.CreateBranch takes a config.Config as input (why?), and also seems to modify the repository's .git/config file (again, why?). I've verified that the git branch <branchname> command doesn't touch the repo's config, and I certainly don't need to mention anything about the config when I invoke that command.

Second, the SO answer that I linked above cites code in go-git's repository_test.go that does the following:

r, _ := Init(memory.NewStorage(), nil) // init repo
testBranch := &config.Branch{
    Name:   "foo",
    Remote: "origin",
    Merge:  "refs/heads/foo",
}
err := r.CreateBranch(testBranch)

But the definition of config.Branch is:

type Branch struct {
    // Name of branch
    Name string
    // Remote name of remote to track
    Remote string
    // Merge is the local refspec for the branch <=== ???
    Merge plumbing.ReferenceName
    ...
}

and "refs/heads/foo" isn't a refspec (since a refspec has a : separating its src and dst components).

After much head-scratching and code-reading I've come to the (very) tentative conclusion that the word "refspec" in the comment must be wrong, and it should instead just be "ref". But I'm not at all sure about this: if I'm right, then why is this field named Merge instead of just Ref?

Another tentative conclusion is that Repository.CreateBranch isn't really for creating a purely local branch, but rather, for creating a local branch that stands in some sort of relation to a branch on a remote -- for example, if I were pulling someone else's branch from the remote.

Actually, on a re-reading of the Repository.CreateBranch method, I'm not at all convinced that it really creates a branch at all (that is, that it creates .git/refs/heads/<branchname>). Unless I'm missing something (entirely possible), it seems that all it does is create a [branch "<name>"] section in .git/config. But if that's true, why is it a method of Repository at all? Why is it not a method of config.Config?

Similarly, there's a related function:

func (r *Repository) Branch(name string) (*config.Branch, error)

that will only return branch information from the config. Yet, the very next function in the documentation of Repository is:

func (r *Repository) Branches() (storer.ReferenceIter, error) 

which really does return an iterator over all the entries in .git/refs/heads/.

This is horribly confusing, and the documentation (such as it is) doesn't help matters. In any case, unless someone can convince me otherwise, I'm pretty sure that CreateBranch won't be of much help in actually creating a branch.

Worktree.Checkout ???

Some additional web-searching turned up these two issues from the old d-src/go-git repo:

Both of these posts suggest this basic approach to creating the local branch:

wt, err := repo.Worktree()                                                                                                                                                                                                                           
if err != nil {                                                                                                                                                                                                                                  
        // deal with it                                                                                                                                                                                                                                   
}                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               
err = w.Checkout(&git.CheckoutOptions{                                                                                                                                                                                                           
        Create: true,                                                                                                                                                                                                                            
        Force:  false,                                                                                                                                                                                                                           
        Branch: plumbing.ReferenceName("refs/heads/<branchname>"),                                                                                                                                                                                
})

Apart from the fact that this checks out the new branch, which git branch <branchname> doesn't do, it also fails to create .git/logs/refs/heads/<branchname>.

Also -- as a potentially very nasty surprise -- it blows away all the untracked files in the worktree. By default, git checkout keeps local modifications to the files in the working tree, but in go-git you need to explicitly specify Keep: true, even if you've specified Force: false.

Definitely a violation of the "Principle of Least Astonishment." Thankfully, in the local repo I tested this in, they were all old editor backup files or fragments of old projects that I'd long ago abandoned.

storer.ReferenceStorer

As it happened, one of the go-git authors/maintainers responded to the second issue, and suggested:

In order to create and remove references independent of the Worktree, you should do this using the storer.ReferenceStorer.

Please take a look at the branch example: https://github.com/src-d/go-git/blob/master/_examples/branch/main.go

Which is fine and straightforward, but it only addresses creation of the branch's ref.

All occurrences of the word "log" that I have been able to find in the go-git source code seem to refer to commit logs, not ref logs. Given that reflog entries don't look anything like other artifacts in the .git tree, I'd imagine that a different kind of storer would be necessary to create/update them -- and none of the existing storers look like (to me) they do that.

So...

Any suggestions on how I should get a proper reflog to go with the ref?

(Or, maybe I've misunderstood horribly, and there is some way of creating branches in go-git, apart from those I've listed above, that would do what I want.)

2

There are 2 answers

1
Pedro Luz On

Whe way I've done it:

Create a local reference to the new branch

branchName := "new-branch"
localRef := plumbing.NewBranchReferenceName(branchName)

Create the branch

opts := &gitConfig.Branch{
    Name:   branchName,
    Remote: "origin",
    Merge:  localRef,
}

if err := repo.CreateBranch(opts); err != nil {
    return err
}

In case you actually need to change to that branch... just do a checkout (can't remember if it actualy changes to the created branch with the create)

Get the working tree

w, err := repo.Worktree()
if err != nil {
    return rest.InternalServerError(err.Error())
}

Checkout

if err := w.Checkout(&git.CheckoutOptions{Branch: plumbing.ReferenceName(localRef.String())}); err != nil {
    return nil
}

if you want to track against a remote branch

Create a remote reference

remoteRef := plumbing.NewRemoteReferenceName("origin", branchName)

track remote

newReference := plumbing.NewSymbolicReference(localRef, remoteRef)

if err := repo.Storer.SetReference(newReference); err != nil {
   return err
}
1
Bojan Popržen On

Firstly, I don't have enough reputation to comment on Pedro's answer, but his approach fails on the Checkout phase as no branch is actually created on the storage (the repo's Storer was never invoked).

Secondly, it's the first time I heard about .git/log dir, so no, git branch does not create a record for the branch in that dir.

This leads me to the actual solution which is the one provided as an example of branching at the go-git repo.

  • To create a branch (off of HEAD):
Info("git branch test")
branchName := plumbing.NewBranchReferenceName("test")
headRef, err := r.Head()
CheckIfError(err)
ref := plumbing.NewHashReference(branchName, headRef.Hash())
err = r.Storer.SetReference(ref)
CheckIfError(err)
  • To checkout a branch
Info("git checkout test")
w, err := r.Worktree()
CheckIfError(err)
err = w.Checkout(&git.CheckoutOptions{Branch: ref.Name()})
CheckIfError(err)

This way, however, there is no config for this branch at .git/config, so there should be a call to repo.Branch function, but this is really comically unintuitive.