8

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.)

Hephaestus
  • 1,982
  • 3
  • 27
  • 35
  • Without looking at the go-git side of things to verify any of this (because it seems clear to me :-) ): the config argument is simply "how to set up the branch's upstream". The upstream setting of a branch, in Git, consists of two parts: a *remote*, and a "merge". The remote is just a string: any existing remote name is fine and means that particular remote, and "." means "this repository". The "merge" part is where things are squirrelly: it's the *ref* (not refspec, you're right) of the branch *as seen on the remote*. – torek Apr 18 '21 at 20:38
  • This means that if the "remote" is a literal "." string, it's the branch name, spelled out. If the remote is, say, `origin` instead, it's the branch name *as seen on the Git at `origin`*. If you have a refspec that maps `+refs/heads/*:refs/remotes/origin/*`, their `main` is your `origin/main`, and that's what you'd pass to `git merge`, but the *merge* line says `refs/heads/main`, not `refs/remotes/origin/main`. If your refspec says `+refs/heads/main:refs/remotes/origin/FooledYa`, you still list `refs/heads/main` here. – torek Apr 18 '21 at 20:40
  • Git will create a reflog when creating a branch based on the `core.logAllRefUpdates` setting, so go-git probably should also obey this setting. – torek Apr 18 '21 at 20:42
  • @torek Aha, your comment got me to look at the man page for `git config` where I found the info about `branch..merge`. So, yeah, `Merge` as a field name now makes sense to me. But why this method is named `CreateBranch`, and why it's a method of the `Repository` type, and not the `Config` type, are beyond me. – Hephaestus Apr 19 '21 at 19:02

2 Answers2

5

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.

  • This does not provide an answer to the question. Once you have sufficient [reputation](https://stackoverflow.com/help/whats-reputation) you will be able to [comment on any post](https://stackoverflow.com/help/privileges/comment); instead, [provide answers that don't require clarification from the asker](https://meta.stackexchange.com/questions/214173/why-do-i-need-50-reputation-to-comment-what-can-i-do-instead). - [From Review](/review/late-answers/30354617) – Bracken Nov 16 '21 at 13:47
1

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
}
Dharman
  • 30,962
  • 25
  • 85
  • 135
Pedro Luz
  • 2,694
  • 4
  • 44
  • 54