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".
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", "bob@example.com", time.Now()},
Committer: object.Signature{"Bob", "bob@example.com", 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", "alice@example.com", time.Now()},
Committer: object.Signature{"Alice", "alice@example.com", 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.