diff options
Diffstat (limited to 'repository')
-rw-r--r-- | repository/gogit.go | 378 | ||||
-rw-r--r-- | repository/gogit_config.go | 175 | ||||
-rw-r--r-- | repository/gogit_test.go | 68 | ||||
-rw-r--r-- | repository/gogit_testing.go | 58 |
4 files changed, 679 insertions, 0 deletions
diff --git a/repository/gogit.go b/repository/gogit.go new file mode 100644 index 00000000..71a7e6d0 --- /dev/null +++ b/repository/gogit.go @@ -0,0 +1,378 @@ +package repository + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + stdpath "path" + "path/filepath" + "sync" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + + "github.com/MichaelMure/git-bug/util/lamport" +) + +var _ ClockedRepo = &GoGitRepo{} + +type GoGitRepo struct { + r *gogit.Repository + path string + + clocksMutex sync.Mutex + clocks map[string]lamport.Clock +} + +func NewGoGitRepo(path string, clockLoaders []ClockLoader) (*GoGitRepo, error) { + path, err := detectGitPath(path) + if err != nil { + return nil, err + } + + r, err := gogit.PlainOpen(path) + if err != nil { + return nil, err + } + + repo := &GoGitRepo{ + r: r, + path: path, + clocks: make(map[string]lamport.Clock), + } + + for _, loader := range clockLoaders { + allExist := true + for _, name := range loader.Clocks { + if _, err := repo.getClock(name); err != nil { + allExist = false + } + } + + if !allExist { + err = loader.Witnesser(repo) + if err != nil { + return nil, err + } + } + } + + return repo, nil +} + +func detectGitPath(path string) (string, error) { + // normalize the path + path, err := filepath.Abs(path) + if err != nil { + return "", err + } + + for { + fi, err := os.Stat(stdpath.Join(path, ".git")) + if err == nil { + if !fi.IsDir() { + return "", fmt.Errorf(".git exist but is not a directory") + } + return stdpath.Join(path, ".git"), nil + } + if !os.IsNotExist(err) { + // unknown error + return "", err + } + + // detect bare repo + ok, err := isGitDir(path) + if err != nil { + return "", err + } + if ok { + return path, nil + } + + if parent := filepath.Dir(path); parent == path { + return "", fmt.Errorf(".git not found") + } else { + path = parent + } + } +} + +func isGitDir(path string) (bool, error) { + markers := []string{"HEAD", "objects", "refs"} + + for _, marker := range markers { + _, err := os.Stat(stdpath.Join(path, marker)) + if err == nil { + continue + } + if !os.IsNotExist(err) { + // unknown error + return false, err + } else { + return false, nil + } + } + + return true, nil +} + +// InitGoGitRepo create a new empty git repo at the given path +func InitGoGitRepo(path string) (*GoGitRepo, error) { + r, err := gogit.PlainInit(path, false) + if err != nil { + return nil, err + } + + return &GoGitRepo{ + r: r, + path: path + "/.git", + clocks: make(map[string]lamport.Clock), + }, nil +} + +// InitBareGoGitRepo create a new --bare empty git repo at the given path +func InitBareGoGitRepo(path string) (*GoGitRepo, error) { + r, err := gogit.PlainInit(path, true) + if err != nil { + return nil, err + } + + return &GoGitRepo{ + r: r, + path: path, + clocks: make(map[string]lamport.Clock), + }, nil +} + +func (repo *GoGitRepo) LocalConfig() Config { + return newGoGitConfig(repo.r) +} + +func (repo *GoGitRepo) GlobalConfig() Config { + panic("go-git doesn't support writing global config") +} + +// GetPath returns the path to the repo. +func (repo *GoGitRepo) GetPath() string { + return repo.path +} + +// GetUserName returns the name the the user has used to configure git +func (repo *GoGitRepo) GetUserName() (string, error) { + cfg, err := repo.r.Config() + if err != nil { + return "", err + } + + return cfg.User.Name, nil +} + +// GetUserEmail returns the email address that the user has used to configure git. +func (repo *GoGitRepo) GetUserEmail() (string, error) { + cfg, err := repo.r.Config() + if err != nil { + return "", err + } + + return cfg.User.Email, nil +} + +// GetCoreEditor returns the name of the editor that the user has used to configure git. +func (repo *GoGitRepo) GetCoreEditor() (string, error) { + + panic("implement me") +} + +// GetRemotes returns the configured remotes repositories. +func (repo *GoGitRepo) GetRemotes() (map[string]string, error) { + cfg, err := repo.r.Config() + if err != nil { + return nil, err + } + + result := make(map[string]string, len(cfg.Remotes)) + for name, remote := range cfg.Remotes { + if len(remote.URLs) > 0 { + result[name] = remote.URLs[0] + } + } + + return result, nil +} + +// FetchRefs fetch git refs from a remote +func (repo *GoGitRepo) FetchRefs(remote string, refSpec string) (string, error) { + buf := bytes.NewBuffer(nil) + + err := repo.r.Fetch(&gogit.FetchOptions{ + RemoteName: remote, + RefSpecs: []config.RefSpec{config.RefSpec(refSpec)}, + Progress: buf, + }) + if err != nil { + return "", err + } + + return buf.String(), nil +} + +// PushRefs push git refs to a remote +func (repo *GoGitRepo) PushRefs(remote string, refSpec string) (string, error) { + buf := bytes.NewBuffer(nil) + + err := repo.r.Push(&gogit.PushOptions{ + RemoteName: remote, + RefSpecs: []config.RefSpec{config.RefSpec(refSpec)}, + Progress: buf, + }) + if err != nil { + return "", err + } + + return buf.String(), nil +} + +// StoreData will store arbitrary data and return the corresponding hash +func (repo *GoGitRepo) StoreData(data []byte) (Hash, error) { + obj := repo.r.Storer.NewEncodedObject() + obj.SetType(plumbing.BlobObject) + + w, err := obj.Writer() + if err != nil { + return "", err + } + + _, err = w.Write(data) + if err != nil { + return "", err + } + + h, err := repo.r.Storer.SetEncodedObject(obj) + if err != nil { + return "", err + } + + return Hash(h.String()), nil +} + +// ReadData will attempt to read arbitrary data from the given hash +func (repo *GoGitRepo) ReadData(hash Hash) ([]byte, error) { + obj, err := repo.r.BlobObject(plumbing.NewHash(hash.String())) + if err != nil { + return nil, err + } + + r, err := obj.Reader() + if err != nil { + return nil, err + } + + return ioutil.ReadAll(r) +} + +func (repo *GoGitRepo) StoreTree(mapping []TreeEntry) (Hash, error) { + panic("implement me") +} + +func (repo *GoGitRepo) ReadTree(hash Hash) ([]TreeEntry, error) { + panic("implement me") +} + +func (repo *GoGitRepo) StoreCommit(treeHash Hash) (Hash, error) { + panic("implement me") +} + +func (repo *GoGitRepo) StoreCommitWithParent(treeHash Hash, parent Hash) (Hash, error) { + panic("implement me") +} + +func (repo *GoGitRepo) GetTreeHash(commit Hash) (Hash, error) { + panic("implement me") +} + +func (repo *GoGitRepo) FindCommonAncestor(commit1 Hash, commit2 Hash) (Hash, error) { + panic("implement me") +} + +func (repo *GoGitRepo) UpdateRef(ref string, hash Hash) error { + panic("implement me") +} + +func (repo *GoGitRepo) RemoveRef(ref string) error { + panic("implement me") +} + +func (repo *GoGitRepo) ListRefs(refspec string) ([]string, error) { + panic("implement me") +} + +func (repo *GoGitRepo) RefExist(ref string) (bool, error) { + panic("implement me") +} + +func (repo *GoGitRepo) CopyRef(source string, dest string) error { + panic("implement me") +} + +func (repo *GoGitRepo) ListCommits(ref string) ([]Hash, error) { + panic("implement me") +} + +// GetOrCreateClock return a Lamport clock stored in the Repo. +// If the clock doesn't exist, it's created. +func (repo *GoGitRepo) GetOrCreateClock(name string) (lamport.Clock, error) { + c, err := repo.getClock(name) + if err == nil { + return c, nil + } + if err != ErrClockNotExist { + return nil, err + } + + repo.clocksMutex.Lock() + defer repo.clocksMutex.Unlock() + + p := clockPath + name + "-clock" + + c, err = lamport.NewPersistedClock(p) + if err != nil { + return nil, err + } + + repo.clocks[name] = c + return c, nil +} + +func (repo *GoGitRepo) getClock(name string) (lamport.Clock, error) { + repo.clocksMutex.Lock() + defer repo.clocksMutex.Unlock() + + if c, ok := repo.clocks[name]; ok { + return c, nil + } + + p := clockPath + name + "-clock" + + c, err := lamport.LoadPersistedClock(p) + if err == nil { + repo.clocks[name] = c + return c, nil + } + if err == lamport.ErrClockNotExist { + return nil, ErrClockNotExist + } + return nil, err +} + +// AddRemote add a new remote to the repository +// Not in the interface because it's only used for testing +func (repo *GoGitRepo) AddRemote(name string, url string) error { + _, err := repo.r.CreateRemote(&config.RemoteConfig{ + Name: name, + URLs: []string{url}, + }) + + return err +} diff --git a/repository/gogit_config.go b/repository/gogit_config.go new file mode 100644 index 00000000..0f91b092 --- /dev/null +++ b/repository/gogit_config.go @@ -0,0 +1,175 @@ +package repository + +import ( + "fmt" + "strconv" + "strings" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/format/config" +) + +var _ Config = &goGitConfig{} + +type goGitConfig struct { + repo *gogit.Repository +} + +func newGoGitConfig(repo *gogit.Repository) *goGitConfig { + return &goGitConfig{repo: repo} +} + +func (ggc *goGitConfig) StoreString(key, value string) error { + cfg, err := ggc.repo.Config() + if err != nil { + return err + } + + split := strings.Split(key, ".") + + switch { + case len(split) <= 1: + return fmt.Errorf("invalid key") + case len(split) == 2: + cfg.Raw.Section(split[0]).SetOption(split[1], value) + default: + section := split[0] + subsection := strings.Join(split[1:len(split)-2], ".") + option := split[len(split)-1] + cfg.Raw.Section(section).Subsection(subsection).SetOption(option, value) + } + + return ggc.repo.SetConfig(cfg) +} + +func (ggc *goGitConfig) StoreTimestamp(key string, value time.Time) error { + return ggc.StoreString(key, strconv.Itoa(int(value.Unix()))) +} + +func (ggc *goGitConfig) StoreBool(key string, value bool) error { + return ggc.StoreString(key, strconv.FormatBool(value)) +} + +func (ggc *goGitConfig) ReadAll(keyPrefix string) (map[string]string, error) { + cfg, err := ggc.repo.Config() + if err != nil { + return nil, err + } + + split := strings.Split(keyPrefix, ".") + + var opts config.Options + + switch { + case len(split) < 1: + return nil, fmt.Errorf("invalid key prefix") + case len(split) == 1: + opts = cfg.Raw.Section(split[0]).Options + default: + section := split[0] + subsection := strings.Join(split[1:len(split)-1], ".") + opts = cfg.Raw.Section(section).Subsection(subsection).Options + } + + if len(opts) == 0 { + return nil, fmt.Errorf("invalid section") + } + + if keyPrefix[len(keyPrefix)-1:] != "." { + keyPrefix += "." + } + + result := make(map[string]string, len(opts)) + for _, opt := range opts { + result[keyPrefix+opt.Key] = opt.Value + } + + return result, nil +} + +func (ggc *goGitConfig) ReadBool(key string) (bool, error) { + val, err := ggc.ReadString(key) + if err != nil { + return false, err + } + + return strconv.ParseBool(val) +} + +func (ggc *goGitConfig) ReadString(key string) (string, error) { + cfg, err := ggc.repo.Config() + if err != nil { + return "", err + } + + split := strings.Split(key, ".") + + // TODO: return ErrNoConfigEntry and ErrMultipleConfigEntry + // Can use forked go-git: https://github.com/go-git/go-git/pull/112 + + switch { + case len(split) <= 1: + return "", fmt.Errorf("invalid key") + case len(split) == 2: + return cfg.Raw.Section(split[0]).Option(split[1]), nil + default: + section := split[0] + subsection := strings.Join(split[1:len(split)-2], ".") + option := split[len(split)-1] + return cfg.Raw.Section(section).Subsection(subsection).Option(option), nil + } +} + +func (ggc *goGitConfig) ReadTimestamp(key string) (time.Time, error) { + value, err := ggc.ReadString(key) + if err != nil { + return time.Time{}, err + } + return ParseTimestamp(value) +} + +func (ggc *goGitConfig) RemoveAll(keyPrefix string) error { + cfg, err := ggc.repo.Config() + if err != nil { + return err + } + + split := strings.Split(keyPrefix, ".") + + // missing in go-git + hasOption := func(options config.Options, key string) bool { + for _, option := range options { + if option.IsKey(key) { + return true + } + } + return false + } + + switch { + case len(split) < 1: + return fmt.Errorf("invalid key prefix") + case len(split) == 1: + if len(cfg.Raw.Section(split[0]).Options) > 0 { + cfg.Raw.RemoveSection(split[0]) + } else { + return fmt.Errorf("invalid key prefix") + } + default: + section := split[0] + rest := strings.Join(split[1:], ".") + + if cfg.Raw.Section(section).HasSubsection(rest) { + cfg.Raw.RemoveSubsection(section, rest) + } else { + if hasOption(cfg.Raw.Section(section).Options, rest) { + cfg.Raw.Section(section).RemoveOption(rest) + } else { + return fmt.Errorf("invalid key prefix") + } + } + } + + return ggc.repo.SetConfig(cfg) +} diff --git a/repository/gogit_test.go b/repository/gogit_test.go new file mode 100644 index 00000000..9dcf109f --- /dev/null +++ b/repository/gogit_test.go @@ -0,0 +1,68 @@ +package repository + +import ( + "io/ioutil" + "os" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewGoGitRepo(t *testing.T) { + // Plain + plainRoot, err := ioutil.TempDir("", "") + require.NoError(t, err) + defer os.RemoveAll(plainRoot) + + _, err = InitGoGitRepo(plainRoot) + require.NoError(t, err) + plainGitDir := path.Join(plainRoot, ".git") + + // Bare + bareRoot, err := ioutil.TempDir("", "") + require.NoError(t, err) + defer os.RemoveAll(bareRoot) + + _, err = InitBareGoGitRepo(bareRoot) + require.NoError(t, err) + bareGitDir := bareRoot + + tests := []struct { + inPath string + outPath string + err bool + }{ + // errors + {"/", "", true}, + // parent dir of a repo + {filepath.Dir(plainRoot), "", true}, + + // Plain repo + {plainRoot, plainGitDir, false}, + {plainGitDir, plainGitDir, false}, + {path.Join(plainGitDir, "objects"), plainGitDir, false}, + + // Bare repo + {bareRoot, bareGitDir, false}, + {bareGitDir, bareGitDir, false}, + {path.Join(bareGitDir, "objects"), bareGitDir, false}, + } + + for i, tc := range tests { + r, err := NewGoGitRepo(tc.inPath, nil) + + if tc.err { + require.Error(t, err, i) + } else { + require.NoError(t, err, i) + assert.Equal(t, tc.outPath, r.GetPath(), i) + } + } +} + +func TestGoGitRepo(t *testing.T) { + RepoTest(t, CreateGoGitTestRepo, CleanupTestRepos) +} diff --git a/repository/gogit_testing.go b/repository/gogit_testing.go new file mode 100644 index 00000000..f20ff6be --- /dev/null +++ b/repository/gogit_testing.go @@ -0,0 +1,58 @@ +package repository + +import ( + "io/ioutil" + "log" +) + +// This is intended for testing only + +func CreateGoGitTestRepo(bare bool) TestedRepo { + dir, err := ioutil.TempDir("", "") + if err != nil { + log.Fatal(err) + } + + var creator func(string) (*GoGitRepo, error) + + if bare { + creator = InitBareGoGitRepo + } else { + creator = InitGoGitRepo + } + + repo, err := creator(dir) + if err != nil { + log.Fatal(err) + } + + config := repo.LocalConfig() + if err := config.StoreString("user.name", "testuser"); err != nil { + log.Fatal("failed to set user.name for test repository: ", err) + } + if err := config.StoreString("user.email", "testuser@example.com"); err != nil { + log.Fatal("failed to set user.email for test repository: ", err) + } + + return repo +} + +func SetupGoGitReposAndRemote() (repoA, repoB, remote TestedRepo) { + repoA = CreateGoGitTestRepo(false) + repoB = CreateGoGitTestRepo(false) + remote = CreateGoGitTestRepo(true) + + remoteAddr := "file://" + remote.GetPath() + + err := repoA.AddRemote("origin", remoteAddr) + if err != nil { + log.Fatal(err) + } + + err = repoB.AddRemote("origin", remoteAddr) + if err != nil { + log.Fatal(err) + } + + return repoA, repoB, remote +} |