'How do I mimic `git --work-tree ...` with `go-git` in go?

I have a bare repository in which I need to add and commit a set of files. As far as I understand it, adding files to the index requires a worktree. Using git on the command line, I would set the git-dir option to point to the bare directory along with setting the work-tree option to point to a worktree in which the files to be added to the index live. Like so:

$ git --git-dir /path/to/.git --work-tree /path/to/worktree add ...

It's worth mentioning that the ".git" directory is not, and can not, be named simply ".git". It is in fact a "custom" ".git" dir. Like git --git-dir /path/to/.notgit ....

I tried setting the core.worktree config option. However, with core.bare set to true this results in a fatal error. Both from the command line:

$ git --git-dir /path/to/.notgit config core.worktree /path/to/worktree
$ git --git-dir /path/to/.notgit add ...
warning: core.bare and core.worktree do not make sense
fatal: unable to set up work tree using invalid config

and using go-git:

r, err := git.PlainOpen("/path/to/.notgit")
panicOnError(err)

c, err := r.Config()
panicOnError(err)

fmt.Println(c.Core.IsBare) // true

c.Core.Worktree = "/path/to/worktree"

err = r.SetConfig(c)
panicOnError(err)

_, err = r.Worktree() // panic: worktree not available in a bare repository
panicOnError(err)

One thought I had was to lean on the git.PlainOpenWithOptions function to hopefully allow me to provide a worktree as an option. However, looking at the git.PlainOpenOptions struct type, this fell apart quickly.

type PlainOpenOptions struct {
    // DetectDotGit defines whether parent directories should be
    // walked until a .git directory or file is found.
    DetectDotGit bool
    // Enable .git/commondir support (see https://git-scm.com/docs/gitrepository-layout#Documentation/gitrepository-layout.txt).
    // NOTE: This option will only work with the filesystem storage.
    EnableDotGitCommonDir bool
}

How do I mimic git --work-tree ... with go-git?


Edit 1: Explained that ".git" is not exactly named ".git".



Solution 1:[1]

When you use git.Open(), it basically sets worktree field in repository struct as nil, since it uses PlainOpenWithOptions internally with the default value of DetectDotGit as false. If you use the following constructor, you will see, the untracked files will be added successfully.

    r, err := git.PlainOpenWithOptions("/path/to/.git",&git.PlainOpenOptions{DetectDotGit: true})
    panicOnError(err)
    
    c, err := r.Config()
    panicOnError(err)
    
    fmt.Println(c.Core.IsBare) // true
    
    c.Core.Worktree = "/path/to/worktree"
    
    err = r.SetConfig(c)
    panicOnError(err)
    
    _, err = r.Worktree() // panic: worktree not available in a bare repository
    panicOnError(err)

// added this part for test
    workTree, werr := r.Worktree()
    panicOnError(werr)

    hash, hashErr := workTree.Add("a.txt")
    if hashErr != nil {
        log.Fatal(hashErr)
    }
fmt.Println(hash)

Before go code execution enter image description here

After go code execution enter image description here

Solution 2:[2]

I'm not an expert in Git, but I've been playing with go-git and I've been able to create a bare repository and add a file to it using the Git plumbing commands. It's a bit verbose, but actually straightforward once you get the gist of it. The main thing to realise is that Git has a number of different object types that it uses to perform its work, and we just need to create each of those objects, which is the bulk of the code below.

The following code will create a new, bare repository in /tmp/example.git, and add a file called "README.md" to it, without the need for any working directory. It does need to create an in-memory representation of the file that we want to store, but that representation is just a byte buffer, not a filesystem. (This code will also change the default branch name from "master" to "main"):

package main

import (
    "github.com/go-git/go-git/v5"
    "github.com/go-git/go-git/v5/plumbing"
    "github.com/go-git/go-git/v5/plumbing/filemode"
    "github.com/go-git/go-git/v5/plumbing/object"
    "os"
    "time"
)

func panicIf(err error) {
    if err != nil {
        panic(err)
    }
}

func getRepo() string {
    return "/tmp/example.git"
}

func main() {

    dir := getRepo()
    err := os.Mkdir(dir, 0700)
    panicIf(err)

    // Create a new repo
    r, err := git.PlainInit(dir, true)
    panicIf(err)

    // Change it to use "main" instead of "master"
    h := plumbing.NewSymbolicReference(plumbing.HEAD, "refs/heads/main")
    err = r.Storer.SetReference(h)
    panicIf(err)

    // Create a file in storage. It's identified by its hash.
    fileObject := plumbing.MemoryObject{}
    fileObject.SetType(plumbing.BlobObject)
    w, err := fileObject.Writer()
    panicIf(err)

    _, err = w.Write([]byte("# My Story\n"))
    panicIf(err)

    err = w.Close()
    panicIf(err)

    fileHash, err := r.Storer.SetEncodedObject(&fileObject)
    panicIf(err)

    // Create and store a Tree that contains the stored object.
    // Give it the name "README.md".

    treeEntry := object.TreeEntry{
        Name: "README.md",
        Mode: filemode.Regular,
        Hash: fileHash,
    }

    tree := object.Tree{
        Entries: []object.TreeEntry{treeEntry},
    }

    treeObject := plumbing.MemoryObject{}
    err = tree.Encode(&treeObject)
    panicIf(err)

    treeHash, err := r.Storer.SetEncodedObject(&treeObject)
    panicIf(err)

    // Next, create a commit that references the tree
    // A commit is just metadata about a tree.

    commit := object.Commit{
        Author:    object.Signature{"Bob", "[email protected]", time.Now()},
        Committer: object.Signature{"Bob", "[email protected]", time.Now()},
        Message:   "first commit",
        TreeHash:  treeHash,
    }

    commitObject := plumbing.MemoryObject{}
    err = commit.Encode(&commitObject)
    panicIf(err)

    commitHash, err := r.Storer.SetEncodedObject(&commitObject)
    panicIf(err)

    // Now, point the "main" branch to the newly-created commit

    ref := plumbing.NewHashReference("refs/heads/main", commitHash)
    err = r.Storer.SetReference(ref)

    cfg, err := r.Config()
    panicIf(err)

    // Tell Git that the default branch name is "main".

    cfg.Init.DefaultBranch = "main"
    err = r.SetConfig(cfg)
    panicIf(err)
}

Once you've run this code, to see that it's working, you can clone the resulting bar repo using the command line version of git. Assuming the current directory is /tmp, this is simple:

/tmp $ git clone example.git
Cloning into 'example'...
done.

This will create a working tree in the /tmp/example directory, which you can cd to:

/tmp $ cd example
/tmp/example $ ls
README.md

You can use a similar technique to add a new file to the bare repo, without the need for a working directory. The following code adds a file called "example.md" to the repo. (Note, this code is naive; if you run it twice, it will create two entries for the same file, which you shouldn't normally do; see the go-git docs for the API to look up a TreeEntry instead of adding one):

package main

import (
    "github.com/go-git/go-git/v5"
    "github.com/go-git/go-git/v5/plumbing"
    "github.com/go-git/go-git/v5/plumbing/filemode"
    "github.com/go-git/go-git/v5/plumbing/object"
    "io"
    "os"
    "time"
)

func panicIf(err error) {
    if err != nil {
        panic(err)
    }
}

func getRepo() string {
    return "/tmp/example.git"
}

// Add or replace a single file in a bare repository.
// This creates a new commit, containing the file.
// You can change the file or add a new file.
//
func main() {
    dir := getRepo()
    repo, err := git.PlainOpen(dir)
    if err != nil {
        panic(err)
    }

    // Get a reference to head of the "main" branch.
    mainRef, err := repo.Reference(plumbing.ReferenceName("refs/heads/main"), true)
    panicIf(err)

    commit, err := repo.CommitObject(mainRef.Hash())
    panicIf(err)

    // Get the tree referred to in the commit.
    tree, err := repo.TreeObject(commit.TreeHash)
    panicIf(err)

    // Copy the file into the repository
    fileObject := plumbing.MemoryObject{}
    fileObject.SetType(plumbing.BlobObject)
    w, err := fileObject.Writer()
    panicIf(err)

    file, err := os.Open("example.md")
    panicIf(err)

    _, err = io.Copy(w, file)
    panicIf(err)

    err = w.Close()
    panicIf(err)

    fileHash, err := repo.Storer.SetEncodedObject(&fileObject)
    panicIf(err)

    // Add a new entry to the tree, and save it into storage.

    newTreeEntry := object.TreeEntry{
        Name: "example.md",
        Mode: filemode.Regular,
        Hash: fileHash,
    }

    tree.Entries = append(tree.Entries, newTreeEntry)

    treeObject := plumbing.MemoryObject{}
    err = tree.Encode(&treeObject)
    panicIf(err)

    treeHash, err := repo.Storer.SetEncodedObject(&treeObject)
    panicIf(err)

    // Next, create a commit that references the previous commit, as well as the new tree

    newCommit := object.Commit{
        Author:       object.Signature{"Alice", "[email protected]", time.Now()},
        Committer:    object.Signature{"Alice", "[email protected]", time.Now()},
        Message:      "second commit",
        TreeHash:     treeHash,
        ParentHashes: []plumbing.Hash{commit.Hash},
    }

    commitObject := plumbing.MemoryObject{}
    err = newCommit.Encode(&commitObject)
    panicIf(err)

    commitHash, err := repo.Storer.SetEncodedObject(&commitObject)
    panicIf(err)

    // Now, point the "main" branch to the newly-created commit

    ref := plumbing.NewHashReference("refs/heads/main", commitHash)
    err = repo.Storer.SetReference(ref)
    panicIf(err)
}

To run this you will need to create a file called "example.md" in your working directory, maybe like this:

$ echo "# An example file" > example.md
$ go build
$ ./add

After running the add command, you can git pull in the working directory:

/tmp/example $ git pull
remote: Enumerating objects: 4, done.
remote: Counting objects: 100% (4/4), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (3/3), 266 bytes | 266.00 KiB/s, done.
From /tmp/example
   6f234cc..c248a9d  main       -> origin/main
Updating 6f234cc..c248a9d
Fast-forward
 example.md | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 example.md

and you can see that the file now exists:

/tmp/example $ ls
README.md   example.md
/tmp/example $ cat example.md
# An example file
/tmp/example $

The way this works is to manually manipulate the data structures used by Git itself. We store the file (blob), create a tree containing the file, and create a commit pointing to the tree. It should be similarly easy to update a file or to delete a file, but every operation that changes the head of a branch will need to create a copy of the tree and commit it, similar to how add has done here.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Hüseyin BABAL
Solution 2 Doctor Eval