git.go

  1// Package repository contains helper methods for working with the Git repo.
  2package repository
  3
  4import (
  5	"bytes"
  6	"fmt"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10	"sync"
 11
 12	"github.com/go-git/go-billy/v5"
 13	"github.com/go-git/go-billy/v5/osfs"
 14
 15	"github.com/MichaelMure/git-bug/util/lamport"
 16)
 17
 18const (
 19	clockPath = "git-bug"
 20)
 21
 22var _ ClockedRepo = &GitRepo{}
 23var _ TestedRepo = &GitRepo{}
 24
 25// GitRepo represents an instance of a (local) git repository.
 26type GitRepo struct {
 27	gitCli
 28	path string
 29
 30	clocksMutex sync.Mutex
 31	clocks      map[string]lamport.Clock
 32
 33	keyring Keyring
 34}
 35
 36// NewGitRepo determines if the given working directory is inside of a git repository,
 37// and returns the corresponding GitRepo instance if it is.
 38func NewGitRepo(path string, clockLoaders []ClockLoader) (*GitRepo, error) {
 39	k, err := defaultKeyring()
 40	if err != nil {
 41		return nil, err
 42	}
 43
 44	repo := &GitRepo{
 45		gitCli:  gitCli{path: path},
 46		path:    path,
 47		clocks:  make(map[string]lamport.Clock),
 48		keyring: k,
 49	}
 50
 51	// Check the repo and retrieve the root path
 52	stdout, err := repo.runGitCommand("rev-parse", "--absolute-git-dir")
 53
 54	// Now dir is fetched with "git rev-parse --git-dir". May be it can
 55	// still return nothing in some cases. Then empty stdout check is
 56	// kept.
 57	if err != nil || stdout == "" {
 58		return nil, ErrNotARepo
 59	}
 60
 61	// Fix the path to be sure we are at the root
 62	repo.path = stdout
 63	repo.gitCli.path = stdout
 64
 65	for _, loader := range clockLoaders {
 66		allExist := true
 67		for _, name := range loader.Clocks {
 68			if _, err := repo.getClock(name); err != nil {
 69				allExist = false
 70			}
 71		}
 72
 73		if !allExist {
 74			err = loader.Witnesser(repo)
 75			if err != nil {
 76				return nil, err
 77			}
 78		}
 79	}
 80
 81	return repo, nil
 82}
 83
 84// InitGitRepo create a new empty git repo at the given path
 85func InitGitRepo(path string) (*GitRepo, error) {
 86	repo := &GitRepo{
 87		gitCli: gitCli{path: path},
 88		path:   path + "/.git",
 89		clocks: make(map[string]lamport.Clock),
 90	}
 91
 92	_, err := repo.runGitCommand("init", path)
 93	if err != nil {
 94		return nil, err
 95	}
 96
 97	return repo, nil
 98}
 99
100// InitBareGitRepo create a new --bare empty git repo at the given path
101func InitBareGitRepo(path string) (*GitRepo, error) {
102	repo := &GitRepo{
103		gitCli: gitCli{path: path},
104		path:   path,
105		clocks: make(map[string]lamport.Clock),
106	}
107
108	_, err := repo.runGitCommand("init", "--bare", path)
109	if err != nil {
110		return nil, err
111	}
112
113	return repo, nil
114}
115
116// LocalConfig give access to the repository scoped configuration
117func (repo *GitRepo) LocalConfig() Config {
118	return newGitConfig(repo.gitCli, false)
119}
120
121// GlobalConfig give access to the global scoped configuration
122func (repo *GitRepo) GlobalConfig() Config {
123	return newGitConfig(repo.gitCli, true)
124}
125
126// AnyConfig give access to a merged local/global configuration
127func (repo *GitRepo) AnyConfig() ConfigRead {
128	return mergeConfig(repo.LocalConfig(), repo.GlobalConfig())
129}
130
131// Keyring give access to a user-wide storage for secrets
132func (repo *GitRepo) Keyring() Keyring {
133	return repo.keyring
134}
135
136// GetPath returns the path to the repo.
137func (repo *GitRepo) GetPath() string {
138	return repo.path
139}
140
141// GetUserName returns the name the the user has used to configure git
142func (repo *GitRepo) GetUserName() (string, error) {
143	return repo.runGitCommand("config", "user.name")
144}
145
146// GetUserEmail returns the email address that the user has used to configure git.
147func (repo *GitRepo) GetUserEmail() (string, error) {
148	return repo.runGitCommand("config", "user.email")
149}
150
151// GetCoreEditor returns the name of the editor that the user has used to configure git.
152func (repo *GitRepo) GetCoreEditor() (string, error) {
153	return repo.runGitCommand("var", "GIT_EDITOR")
154}
155
156// GetRemotes returns the configured remotes repositories.
157func (repo *GitRepo) GetRemotes() (map[string]string, error) {
158	stdout, err := repo.runGitCommand("remote", "--verbose")
159	if err != nil {
160		return nil, err
161	}
162
163	lines := strings.Split(stdout, "\n")
164	remotes := make(map[string]string, len(lines))
165
166	for _, line := range lines {
167		if strings.TrimSpace(line) == "" {
168			continue
169		}
170		elements := strings.Fields(line)
171		if len(elements) != 3 {
172			return nil, fmt.Errorf("git remote: unexpected output format: %s", line)
173		}
174
175		remotes[elements[0]] = elements[1]
176	}
177
178	return remotes, nil
179}
180
181// LocalStorage return a billy.Filesystem giving access to $RepoPath/.git/git-bug
182func (repo *GitRepo) LocalStorage() billy.Filesystem {
183	return osfs.New(repo.path)
184}
185
186// FetchRefs fetch git refs from a remote
187func (repo *GitRepo) FetchRefs(remote, refSpec string) (string, error) {
188	stdout, err := repo.runGitCommand("fetch", remote, refSpec)
189
190	if err != nil {
191		return stdout, fmt.Errorf("failed to fetch from the remote '%s': %v", remote, err)
192	}
193
194	return stdout, err
195}
196
197// PushRefs push git refs to a remote
198func (repo *GitRepo) PushRefs(remote string, refSpec string) (string, error) {
199	stdout, stderr, err := repo.runGitCommandRaw(nil, "push", remote, refSpec)
200
201	if err != nil {
202		return stdout + stderr, fmt.Errorf("failed to push to the remote '%s': %v", remote, stderr)
203	}
204	return stdout + stderr, nil
205}
206
207// StoreData will store arbitrary data and return the corresponding hash
208func (repo *GitRepo) StoreData(data []byte) (Hash, error) {
209	var stdin = bytes.NewReader(data)
210
211	stdout, err := repo.runGitCommandWithStdin(stdin, "hash-object", "--stdin", "-w")
212
213	return Hash(stdout), err
214}
215
216// ReadData will attempt to read arbitrary data from the given hash
217func (repo *GitRepo) ReadData(hash Hash) ([]byte, error) {
218	var stdout bytes.Buffer
219	var stderr bytes.Buffer
220
221	err := repo.runGitCommandWithIO(nil, &stdout, &stderr, "cat-file", "-p", string(hash))
222
223	if err != nil {
224		return []byte{}, err
225	}
226
227	return stdout.Bytes(), nil
228}
229
230// StoreTree will store a mapping key-->Hash as a Git tree
231func (repo *GitRepo) StoreTree(entries []TreeEntry) (Hash, error) {
232	buffer := prepareTreeEntries(entries)
233
234	stdout, err := repo.runGitCommandWithStdin(&buffer, "mktree")
235
236	if err != nil {
237		return "", err
238	}
239
240	return Hash(stdout), nil
241}
242
243// StoreCommit will store a Git commit with the given Git tree
244func (repo *GitRepo) StoreCommit(treeHash Hash) (Hash, error) {
245	stdout, err := repo.runGitCommand("commit-tree", string(treeHash))
246
247	if err != nil {
248		return "", err
249	}
250
251	return Hash(stdout), nil
252}
253
254// StoreCommitWithParent will store a Git commit with the given Git tree
255func (repo *GitRepo) StoreCommitWithParent(treeHash Hash, parent Hash) (Hash, error) {
256	stdout, err := repo.runGitCommand("commit-tree", string(treeHash),
257		"-p", string(parent))
258
259	if err != nil {
260		return "", err
261	}
262
263	return Hash(stdout), nil
264}
265
266// UpdateRef will create or update a Git reference
267func (repo *GitRepo) UpdateRef(ref string, hash Hash) error {
268	_, err := repo.runGitCommand("update-ref", ref, string(hash))
269
270	return err
271}
272
273// RemoveRef will remove a Git reference
274func (repo *GitRepo) RemoveRef(ref string) error {
275	_, err := repo.runGitCommand("update-ref", "-d", ref)
276
277	return err
278}
279
280// ListRefs will return a list of Git ref matching the given refspec
281func (repo *GitRepo) ListRefs(refPrefix string) ([]string, error) {
282	stdout, err := repo.runGitCommand("for-each-ref", "--format=%(refname)", refPrefix)
283
284	if err != nil {
285		return nil, err
286	}
287
288	split := strings.Split(stdout, "\n")
289
290	if len(split) == 1 && split[0] == "" {
291		return []string{}, nil
292	}
293
294	return split, nil
295}
296
297// RefExist will check if a reference exist in Git
298func (repo *GitRepo) RefExist(ref string) (bool, error) {
299	stdout, err := repo.runGitCommand("for-each-ref", ref)
300
301	if err != nil {
302		return false, err
303	}
304
305	return stdout != "", nil
306}
307
308// CopyRef will create a new reference with the same value as another one
309func (repo *GitRepo) CopyRef(source string, dest string) error {
310	_, err := repo.runGitCommand("update-ref", dest, source)
311
312	return err
313}
314
315// ListCommits will return the list of commit hashes of a ref, in chronological order
316func (repo *GitRepo) ListCommits(ref string) ([]Hash, error) {
317	stdout, err := repo.runGitCommand("rev-list", "--first-parent", "--reverse", ref)
318
319	if err != nil {
320		return nil, err
321	}
322
323	split := strings.Split(stdout, "\n")
324
325	casted := make([]Hash, len(split))
326	for i, line := range split {
327		casted[i] = Hash(line)
328	}
329
330	return casted, nil
331
332}
333
334// ReadTree will return the list of entries in a Git tree
335func (repo *GitRepo) ReadTree(hash Hash) ([]TreeEntry, error) {
336	stdout, err := repo.runGitCommand("ls-tree", string(hash))
337
338	if err != nil {
339		return nil, err
340	}
341
342	return readTreeEntries(stdout)
343}
344
345// FindCommonAncestor will return the last common ancestor of two chain of commit
346func (repo *GitRepo) FindCommonAncestor(hash1 Hash, hash2 Hash) (Hash, error) {
347	stdout, err := repo.runGitCommand("merge-base", string(hash1), string(hash2))
348
349	if err != nil {
350		return "", err
351	}
352
353	return Hash(stdout), nil
354}
355
356// GetTreeHash return the git tree hash referenced in a commit
357func (repo *GitRepo) GetTreeHash(commit Hash) (Hash, error) {
358	stdout, err := repo.runGitCommand("rev-parse", string(commit)+"^{tree}")
359
360	if err != nil {
361		return "", err
362	}
363
364	return Hash(stdout), nil
365}
366
367// GetOrCreateClock return a Lamport clock stored in the Repo.
368// If the clock doesn't exist, it's created.
369func (repo *GitRepo) GetOrCreateClock(name string) (lamport.Clock, error) {
370	repo.clocksMutex.Lock()
371	defer repo.clocksMutex.Unlock()
372
373	c, err := repo.getClock(name)
374	if err == nil {
375		return c, nil
376	}
377	if err != ErrClockNotExist {
378		return nil, err
379	}
380
381	c, err = lamport.NewPersistedClock(repo.LocalStorage(), name+"-clock")
382	if err != nil {
383		return nil, err
384	}
385
386	repo.clocks[name] = c
387	return c, nil
388}
389
390func (repo *GitRepo) getClock(name string) (lamport.Clock, error) {
391	if c, ok := repo.clocks[name]; ok {
392		return c, nil
393	}
394
395	c, err := lamport.LoadPersistedClock(repo.LocalStorage(), name+"-clock")
396	if err == nil {
397		repo.clocks[name] = c
398		return c, nil
399	}
400	if err == lamport.ErrClockNotExist {
401		return nil, ErrClockNotExist
402	}
403	return nil, err
404}
405
406// AddRemote add a new remote to the repository
407// Not in the interface because it's only used for testing
408func (repo *GitRepo) AddRemote(name string, url string) error {
409	_, err := repo.runGitCommand("remote", "add", name, url)
410
411	return err
412}
413
414// GetLocalRemote return the URL to use to add this repo as a local remote
415func (repo *GitRepo) GetLocalRemote() string {
416	return repo.path
417}
418
419// EraseFromDisk delete this repository entirely from the disk
420func (repo *GitRepo) EraseFromDisk() error {
421	path := filepath.Clean(strings.TrimSuffix(repo.path, string(filepath.Separator)+".git"))
422
423	// fmt.Println("Cleaning repo:", path)
424	return os.RemoveAll(path)
425}