From 8d63c983c982f93cc48d3996d6bd097ddeeb327f Mon Sep 17 00:00:00 2001 From: Michael Muré Date: Sun, 3 Jan 2021 23:59:25 +0100 Subject: WIP --- repository/common.go | 120 +++++++++ repository/git.go | 570 ------------------------------------------- repository/git_cli.go | 56 ----- repository/git_config.go | 221 ----------------- repository/git_test.go | 6 - repository/git_testing.go | 72 ------ repository/gogit.go | 92 ++++--- repository/gogit_testing.go | 8 +- repository/keyring.go | 10 + repository/mock_repo.go | 177 +++++++------- repository/mock_repo_test.go | 4 +- repository/repo.go | 27 +- repository/repo_testing.go | 24 +- 13 files changed, 325 insertions(+), 1062 deletions(-) create mode 100644 repository/common.go delete mode 100644 repository/git.go delete mode 100644 repository/git_cli.go delete mode 100644 repository/git_config.go delete mode 100644 repository/git_test.go delete mode 100644 repository/git_testing.go (limited to 'repository') diff --git a/repository/common.go b/repository/common.go new file mode 100644 index 00000000..7fd7ae19 --- /dev/null +++ b/repository/common.go @@ -0,0 +1,120 @@ +package repository + +import ( + "io" + + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/armor" + "golang.org/x/crypto/openpgp/errors" +) + +// nonNativeMerge is an implementation of a branch merge, for the case where +// the underlying git implementation doesn't support it natively. +func nonNativeMerge(repo RepoData, ref string, otherRef string, treeHashFn func() Hash) error { + commit, err := repo.ResolveRef(ref) + if err != nil { + return err + } + + otherCommit, err := repo.ResolveRef(otherRef) + if err != nil { + return err + } + + if commit == otherCommit { + // nothing to merge + return nil + } + + // fast-forward is possible if otherRef include ref + + otherCommits, err := repo.ListCommits(otherRef) + if err != nil { + return err + } + + fastForwardPossible := false + for _, hash := range otherCommits { + if hash == commit { + fastForwardPossible = true + break + } + } + + if fastForwardPossible { + return repo.UpdateRef(ref, otherCommit) + } + + // fast-forward is not possible, we need to create a merge commit + + // we need a Tree to make the commit, an empty Tree will do + emptyTreeHash, err := repo.StoreTree(nil) + if err != nil { + return err + } + + newHash, err := repo.StoreCommit(emptyTreeHash, commit, otherCommit) + if err != nil { + return err + } + + return repo.UpdateRef(ref, newHash) +} + +// nonNativeListCommits is an implementation for ListCommits, for the case where +// the underlying git implementation doesn't support if natively. +func nonNativeListCommits(repo RepoData, ref string) ([]Hash, error) { + var result []Hash + + stack := make([]Hash, 0, 32) + visited := make(map[Hash]struct{}) + + hash, err := repo.ResolveRef(ref) + if err != nil { + return nil, err + } + + stack = append(stack, hash) + + for len(stack) > 0 { + // pop + hash := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + if _, ok := visited[hash]; ok { + continue + } + + // mark as visited + visited[hash] = struct{}{} + result = append(result, hash) + + commit, err := repo.ReadCommit(hash) + if err != nil { + return nil, err + } + + for _, parent := range commit.Parents { + stack = append(stack, parent) + } + } + + // reverse + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + + return result, nil +} + +// deArmorSignature convert an armored (text serialized) signature into raw binary +func deArmorSignature(armoredSig io.Reader) (io.Reader, error) { + block, err := armor.Decode(armoredSig) + if err != nil { + return nil, err + } + if block.Type != openpgp.SignatureType { + return nil, errors.InvalidArgumentError("expected '" + openpgp.SignatureType + "', got: " + block.Type) + } + return block.Body, nil +} diff --git a/repository/git.go b/repository/git.go deleted file mode 100644 index e89bae87..00000000 --- a/repository/git.go +++ /dev/null @@ -1,570 +0,0 @@ -// Package repository contains helper methods for working with the Git repo. -package repository - -import ( - "bytes" - "fmt" - "io/ioutil" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/blevesearch/bleve" - "github.com/go-git/go-billy/v5" - "github.com/go-git/go-billy/v5/osfs" - - "github.com/MichaelMure/git-bug/util/lamport" -) - -var _ ClockedRepo = &GitRepo{} -var _ TestedRepo = &GitRepo{} - -// GitRepo represents an instance of a (local) git repository. -type GitRepo struct { - gitCli - path string - - clocksMutex sync.Mutex - clocks map[string]lamport.Clock - - indexesMutex sync.Mutex - indexes map[string]bleve.Index - - keyring Keyring - localStorage billy.Filesystem -} - -func (repo *GitRepo) ReadCommit(hash Hash) (Commit, error) { - panic("implement me") -} - -func (repo *GitRepo) ResolveRef(ref string) (Hash, error) { - panic("implement me") -} - -// OpenGitRepo determines if the given working directory is inside of a git repository, -// and returns the corresponding GitRepo instance if it is. -func OpenGitRepo(path string, clockLoaders []ClockLoader) (*GitRepo, error) { - k, err := defaultKeyring() - if err != nil { - return nil, err - } - - repo := &GitRepo{ - gitCli: gitCli{path: path}, - path: path, - clocks: make(map[string]lamport.Clock), - indexes: make(map[string]bleve.Index), - keyring: k, - } - - // Check the repo and retrieve the root path - stdout, err := repo.runGitCommand("rev-parse", "--absolute-git-dir") - - // Now dir is fetched with "git rev-parse --git-dir". May be it can - // still return nothing in some cases. Then empty stdout check is - // kept. - if err != nil || stdout == "" { - return nil, ErrNotARepo - } - - // Fix the path to be sure we are at the root - repo.path = stdout - repo.gitCli.path = stdout - repo.localStorage = osfs.New(filepath.Join(path, "git-bug")) - - 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 -} - -// InitGitRepo create a new empty git repo at the given path -func InitGitRepo(path string) (*GitRepo, error) { - k, err := defaultKeyring() - if err != nil { - return nil, err - } - - repo := &GitRepo{ - gitCli: gitCli{path: path}, - path: filepath.Join(path, ".git"), - clocks: make(map[string]lamport.Clock), - indexes: make(map[string]bleve.Index), - keyring: k, - localStorage: osfs.New(filepath.Join(path, ".git", "git-bug")), - } - - _, err = repo.runGitCommand("init", path) - if err != nil { - return nil, err - } - - return repo, nil -} - -// InitBareGitRepo create a new --bare empty git repo at the given path -func InitBareGitRepo(path string) (*GitRepo, error) { - k, err := defaultKeyring() - if err != nil { - return nil, err - } - - repo := &GitRepo{ - gitCli: gitCli{path: path}, - path: path, - clocks: make(map[string]lamport.Clock), - indexes: make(map[string]bleve.Index), - keyring: k, - localStorage: osfs.New(filepath.Join(path, "git-bug")), - } - - _, err = repo.runGitCommand("init", "--bare", path) - if err != nil { - return nil, err - } - - return repo, nil -} - -func (repo *GitRepo) Close() error { - var firstErr error - for _, index := range repo.indexes { - err := index.Close() - if err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr -} - -// LocalConfig give access to the repository scoped configuration -func (repo *GitRepo) LocalConfig() Config { - return newGitConfig(repo.gitCli, false) -} - -// GlobalConfig give access to the global scoped configuration -func (repo *GitRepo) GlobalConfig() Config { - return newGitConfig(repo.gitCli, true) -} - -// AnyConfig give access to a merged local/global configuration -func (repo *GitRepo) AnyConfig() ConfigRead { - return mergeConfig(repo.LocalConfig(), repo.GlobalConfig()) -} - -// Keyring give access to a user-wide storage for secrets -func (repo *GitRepo) Keyring() Keyring { - return repo.keyring -} - -// GetPath returns the path to the repo. -func (repo *GitRepo) GetPath() string { - return repo.path -} - -// GetUserName returns the name the the user has used to configure git -func (repo *GitRepo) GetUserName() (string, error) { - return repo.runGitCommand("config", "user.name") -} - -// GetUserEmail returns the email address that the user has used to configure git. -func (repo *GitRepo) GetUserEmail() (string, error) { - return repo.runGitCommand("config", "user.email") -} - -// GetCoreEditor returns the name of the editor that the user has used to configure git. -func (repo *GitRepo) GetCoreEditor() (string, error) { - return repo.runGitCommand("var", "GIT_EDITOR") -} - -// GetRemotes returns the configured remotes repositories. -func (repo *GitRepo) GetRemotes() (map[string]string, error) { - stdout, err := repo.runGitCommand("remote", "--verbose") - if err != nil { - return nil, err - } - - lines := strings.Split(stdout, "\n") - remotes := make(map[string]string, len(lines)) - - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - elements := strings.Fields(line) - if len(elements) != 3 { - return nil, fmt.Errorf("git remote: unexpected output format: %s", line) - } - - remotes[elements[0]] = elements[1] - } - - return remotes, nil -} - -// LocalStorage return a billy.Filesystem giving access to $RepoPath/.git/git-bug -func (repo *GitRepo) LocalStorage() billy.Filesystem { - return repo.localStorage -} - -// GetBleveIndex return a bleve.Index that can be used to index documents -func (repo *GitRepo) GetBleveIndex(name string) (bleve.Index, error) { - repo.indexesMutex.Lock() - defer repo.indexesMutex.Unlock() - - if index, ok := repo.indexes[name]; ok { - return index, nil - } - - path := filepath.Join(repo.path, "indexes", name) - - index, err := bleve.Open(path) - if err == nil { - repo.indexes[name] = index - return index, nil - } - - err = os.MkdirAll(path, os.ModeDir) - if err != nil { - return nil, err - } - - mapping := bleve.NewIndexMapping() - mapping.DefaultAnalyzer = "en" - - index, err = bleve.New(path, mapping) - if err != nil { - return nil, err - } - - repo.indexes[name] = index - - return index, nil -} - -// ClearBleveIndex will wipe the given index -func (repo *GitRepo) ClearBleveIndex(name string) error { - repo.indexesMutex.Lock() - defer repo.indexesMutex.Unlock() - - path := filepath.Join(repo.path, "indexes", name) - - err := os.RemoveAll(path) - if err != nil { - return err - } - - delete(repo.indexes, name) - - return nil -} - -// FetchRefs fetch git refs from a remote -func (repo *GitRepo) FetchRefs(remote, refSpec string) (string, error) { - stdout, err := repo.runGitCommand("fetch", remote, refSpec) - - if err != nil { - return stdout, fmt.Errorf("failed to fetch from the remote '%s': %v", remote, err) - } - - return stdout, err -} - -// PushRefs push git refs to a remote -func (repo *GitRepo) PushRefs(remote string, refSpec string) (string, error) { - stdout, stderr, err := repo.runGitCommandRaw(nil, "push", remote, refSpec) - - if err != nil { - return stdout + stderr, fmt.Errorf("failed to push to the remote '%s': %v", remote, stderr) - } - return stdout + stderr, nil -} - -// StoreData will store arbitrary data and return the corresponding hash -func (repo *GitRepo) StoreData(data []byte) (Hash, error) { - var stdin = bytes.NewReader(data) - - stdout, err := repo.runGitCommandWithStdin(stdin, "hash-object", "--stdin", "-w") - - return Hash(stdout), err -} - -// ReadData will attempt to read arbitrary data from the given hash -func (repo *GitRepo) ReadData(hash Hash) ([]byte, error) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := repo.runGitCommandWithIO(nil, &stdout, &stderr, "cat-file", "-p", string(hash)) - - if err != nil { - return []byte{}, err - } - - return stdout.Bytes(), nil -} - -// StoreTree will store a mapping key-->Hash as a Git tree -func (repo *GitRepo) StoreTree(entries []TreeEntry) (Hash, error) { - buffer := prepareTreeEntries(entries) - - stdout, err := repo.runGitCommandWithStdin(&buffer, "mktree") - - if err != nil { - return "", err - } - - return Hash(stdout), nil -} - -// StoreCommit will store a Git commit with the given Git tree -func (repo *GitRepo) StoreCommit(treeHash Hash) (Hash, error) { - stdout, err := repo.runGitCommand("commit-tree", string(treeHash)) - - if err != nil { - return "", err - } - - return Hash(stdout), nil -} - -// StoreCommitWithParent will store a Git commit with the given Git tree -func (repo *GitRepo) StoreCommitWithParent(treeHash Hash, parent Hash) (Hash, error) { - stdout, err := repo.runGitCommand("commit-tree", string(treeHash), - "-p", string(parent)) - - if err != nil { - return "", err - } - - return Hash(stdout), nil -} - -// UpdateRef will create or update a Git reference -func (repo *GitRepo) UpdateRef(ref string, hash Hash) error { - _, err := repo.runGitCommand("update-ref", ref, string(hash)) - - return err -} - -// RemoveRef will remove a Git reference -func (repo *GitRepo) RemoveRef(ref string) error { - _, err := repo.runGitCommand("update-ref", "-d", ref) - - return err -} - -// ListRefs will return a list of Git ref matching the given refspec -func (repo *GitRepo) ListRefs(refPrefix string) ([]string, error) { - stdout, err := repo.runGitCommand("for-each-ref", "--format=%(refname)", refPrefix) - - if err != nil { - return nil, err - } - - split := strings.Split(stdout, "\n") - - if len(split) == 1 && split[0] == "" { - return []string{}, nil - } - - return split, nil -} - -// RefExist will check if a reference exist in Git -func (repo *GitRepo) RefExist(ref string) (bool, error) { - stdout, err := repo.runGitCommand("for-each-ref", ref) - - if err != nil { - return false, err - } - - return stdout != "", nil -} - -// CopyRef will create a new reference with the same value as another one -func (repo *GitRepo) CopyRef(source string, dest string) error { - _, err := repo.runGitCommand("update-ref", dest, source) - - return err -} - -// ListCommits will return the list of commit hashes of a ref, in chronological order -func (repo *GitRepo) ListCommits(ref string) ([]Hash, error) { - stdout, err := repo.runGitCommand("rev-list", "--first-parent", "--reverse", ref) - - if err != nil { - return nil, err - } - - split := strings.Split(stdout, "\n") - - casted := make([]Hash, len(split)) - for i, line := range split { - casted[i] = Hash(line) - } - - return casted, nil - -} - -// ReadTree will return the list of entries in a Git tree -func (repo *GitRepo) ReadTree(hash Hash) ([]TreeEntry, error) { - stdout, err := repo.runGitCommand("ls-tree", string(hash)) - - if err != nil { - return nil, err - } - - return readTreeEntries(stdout) -} - -// FindCommonAncestor will return the last common ancestor of two chain of commit -func (repo *GitRepo) FindCommonAncestor(hash1 Hash, hash2 Hash) (Hash, error) { - stdout, err := repo.runGitCommand("merge-base", string(hash1), string(hash2)) - - if err != nil { - return "", err - } - - return Hash(stdout), nil -} - -// GetTreeHash return the git tree hash referenced in a commit -func (repo *GitRepo) GetTreeHash(commit Hash) (Hash, error) { - stdout, err := repo.runGitCommand("rev-parse", string(commit)+"^{tree}") - - if err != nil { - return "", err - } - - return Hash(stdout), nil -} - -func (repo *GitRepo) AllClocks() (map[string]lamport.Clock, error) { - repo.clocksMutex.Lock() - defer repo.clocksMutex.Unlock() - - result := make(map[string]lamport.Clock) - - files, err := ioutil.ReadDir(filepath.Join(repo.path, "git-bug", clockPath)) - if os.IsNotExist(err) { - return nil, nil - } - if err != nil { - return nil, err - } - - for _, file := range files { - name := file.Name() - if c, ok := repo.clocks[name]; ok { - result[name] = c - } else { - c, err := lamport.LoadPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name)) - if err != nil { - return nil, err - } - repo.clocks[name] = c - result[name] = c - } - } - - return result, nil -} - -// GetOrCreateClock return a Lamport clock stored in the Repo. -// If the clock doesn't exist, it's created. -func (repo *GitRepo) GetOrCreateClock(name string) (lamport.Clock, error) { - repo.clocksMutex.Lock() - defer repo.clocksMutex.Unlock() - - c, err := repo.getClock(name) - if err == nil { - return c, nil - } - if err != ErrClockNotExist { - return nil, err - } - - c, err = lamport.NewPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name)) - if err != nil { - return nil, err - } - - repo.clocks[name] = c - return c, nil -} - -func (repo *GitRepo) getClock(name string) (lamport.Clock, error) { - if c, ok := repo.clocks[name]; ok { - return c, nil - } - - c, err := lamport.LoadPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name)) - if err == nil { - repo.clocks[name] = c - return c, nil - } - if err == lamport.ErrClockNotExist { - return nil, ErrClockNotExist - } - return nil, err -} - -// Increment is equivalent to c = GetOrCreateClock(name) + c.Increment() -func (repo *GitRepo) Increment(name string) (lamport.Time, error) { - c, err := repo.GetOrCreateClock(name) - if err != nil { - return lamport.Time(0), err - } - return c.Increment() -} - -// Witness is equivalent to c = GetOrCreateClock(name) + c.Witness(time) -func (repo *GitRepo) Witness(name string, time lamport.Time) error { - c, err := repo.GetOrCreateClock(name) - if err != nil { - return err - } - return c.Witness(time) -} - -// AddRemote add a new remote to the repository -// Not in the interface because it's only used for testing -func (repo *GitRepo) AddRemote(name string, url string) error { - _, err := repo.runGitCommand("remote", "add", name, url) - - return err -} - -// GetLocalRemote return the URL to use to add this repo as a local remote -func (repo *GitRepo) GetLocalRemote() string { - return repo.path -} - -// EraseFromDisk delete this repository entirely from the disk -func (repo *GitRepo) EraseFromDisk() error { - err := repo.Close() - if err != nil { - return err - } - - path := filepath.Clean(strings.TrimSuffix(repo.path, string(filepath.Separator)+".git")) - - // fmt.Println("Cleaning repo:", path) - return os.RemoveAll(path) -} diff --git a/repository/git_cli.go b/repository/git_cli.go deleted file mode 100644 index 085b1cda..00000000 --- a/repository/git_cli.go +++ /dev/null @@ -1,56 +0,0 @@ -package repository - -import ( - "bytes" - "fmt" - "io" - "os/exec" - "strings" -) - -// gitCli is a helper to launch CLI git commands -type gitCli struct { - path string -} - -// Run the given git command with the given I/O reader/writers, returning an error if it fails. -func (cli gitCli) runGitCommandWithIO(stdin io.Reader, stdout, stderr io.Writer, args ...string) error { - // make sure that the working directory for the command - // always exist, in particular when running "git init". - path := strings.TrimSuffix(cli.path, ".git") - - // fmt.Printf("[%s] Running git %s\n", path, strings.Join(args, " ")) - - cmd := exec.Command("git", args...) - cmd.Dir = path - cmd.Stdin = stdin - cmd.Stdout = stdout - cmd.Stderr = stderr - - return cmd.Run() -} - -// Run the given git command and return its stdout, or an error if the command fails. -func (cli gitCli) runGitCommandRaw(stdin io.Reader, args ...string) (string, string, error) { - var stdout bytes.Buffer - var stderr bytes.Buffer - err := cli.runGitCommandWithIO(stdin, &stdout, &stderr, args...) - return strings.TrimSpace(stdout.String()), strings.TrimSpace(stderr.String()), err -} - -// Run the given git command and return its stdout, or an error if the command fails. -func (cli gitCli) runGitCommandWithStdin(stdin io.Reader, args ...string) (string, error) { - stdout, stderr, err := cli.runGitCommandRaw(stdin, args...) - if err != nil { - if stderr == "" { - stderr = "Error running git command: " + strings.Join(args, " ") - } - err = fmt.Errorf(stderr) - } - return stdout, err -} - -// Run the given git command and return its stdout, or an error if the command fails. -func (cli gitCli) runGitCommand(args ...string) (string, error) { - return cli.runGitCommandWithStdin(nil, args...) -} diff --git a/repository/git_config.go b/repository/git_config.go deleted file mode 100644 index b46cc69b..00000000 --- a/repository/git_config.go +++ /dev/null @@ -1,221 +0,0 @@ -package repository - -import ( - "fmt" - "regexp" - "strconv" - "strings" - "time" - - "github.com/blang/semver" - "github.com/pkg/errors" -) - -var _ Config = &gitConfig{} - -type gitConfig struct { - cli gitCli - localityFlag string -} - -func newGitConfig(cli gitCli, global bool) *gitConfig { - localityFlag := "--local" - if global { - localityFlag = "--global" - } - return &gitConfig{ - cli: cli, - localityFlag: localityFlag, - } -} - -// StoreString store a single key/value pair in the config of the repo -func (gc *gitConfig) StoreString(key string, value string) error { - _, err := gc.cli.runGitCommand("config", gc.localityFlag, "--replace-all", key, value) - return err -} - -func (gc *gitConfig) StoreBool(key string, value bool) error { - return gc.StoreString(key, strconv.FormatBool(value)) -} - -func (gc *gitConfig) StoreTimestamp(key string, value time.Time) error { - return gc.StoreString(key, strconv.Itoa(int(value.Unix()))) -} - -// ReadAll read all key/value pair matching the key prefix -func (gc *gitConfig) ReadAll(keyPrefix string) (map[string]string, error) { - stdout, err := gc.cli.runGitCommand("config", gc.localityFlag, "--includes", "--get-regexp", keyPrefix) - - // / \ - // / ! \ - // ------- - // - // There can be a legitimate error here, but I see no portable way to - // distinguish them from the git error that say "no matching value exist" - if err != nil { - return nil, nil - } - - lines := strings.Split(stdout, "\n") - - result := make(map[string]string, len(lines)) - - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - - parts := strings.SplitN(line, " ", 2) - result[parts[0]] = parts[1] - } - - return result, nil -} - -func (gc *gitConfig) ReadString(key string) (string, error) { - stdout, err := gc.cli.runGitCommand("config", gc.localityFlag, "--includes", "--get-all", key) - - // / \ - // / ! \ - // ------- - // - // There can be a legitimate error here, but I see no portable way to - // distinguish them from the git error that say "no matching value exist" - if err != nil { - return "", ErrNoConfigEntry - } - - lines := strings.Split(stdout, "\n") - - if len(lines) == 0 { - return "", ErrNoConfigEntry - } - if len(lines) > 1 { - return "", ErrMultipleConfigEntry - } - - return lines[0], nil -} - -func (gc *gitConfig) ReadBool(key string) (bool, error) { - val, err := gc.ReadString(key) - if err != nil { - return false, err - } - - return strconv.ParseBool(val) -} - -func (gc *gitConfig) ReadTimestamp(key string) (time.Time, error) { - value, err := gc.ReadString(key) - if err != nil { - return time.Time{}, err - } - return ParseTimestamp(value) -} - -func (gc *gitConfig) rmSection(keyPrefix string) error { - _, err := gc.cli.runGitCommand("config", gc.localityFlag, "--remove-section", keyPrefix) - return err -} - -func (gc *gitConfig) unsetAll(keyPrefix string) error { - _, err := gc.cli.runGitCommand("config", gc.localityFlag, "--unset-all", keyPrefix) - return err -} - -// return keyPrefix section -// example: sectionFromKey(a.b.c.d) return a.b.c -func sectionFromKey(keyPrefix string) string { - s := strings.Split(keyPrefix, ".") - if len(s) == 1 { - return keyPrefix - } - - return strings.Join(s[:len(s)-1], ".") -} - -// rmConfigs with git version lesser than 2.18 -func (gc *gitConfig) rmConfigsGitVersionLT218(keyPrefix string) error { - // try to remove key/value pair by key - err := gc.unsetAll(keyPrefix) - if err != nil { - return gc.rmSection(keyPrefix) - } - - m, err := gc.ReadAll(sectionFromKey(keyPrefix)) - if err != nil { - return err - } - - // if section doesn't have any left key/value remove the section - if len(m) == 0 { - return gc.rmSection(sectionFromKey(keyPrefix)) - } - - return nil -} - -// RmConfigs remove all key/value pair matching the key prefix -func (gc *gitConfig) RemoveAll(keyPrefix string) error { - // starting from git 2.18.0 sections are automatically deleted when the last existing - // key/value is removed. Before 2.18.0 we should remove the section - // see https://github.com/git/git/blob/master/Documentation/RelNotes/2.18.0.txt#L379 - lt218, err := gc.gitVersionLT218() - if err != nil { - return errors.Wrap(err, "getting git version") - } - - if lt218 { - return gc.rmConfigsGitVersionLT218(keyPrefix) - } - - err = gc.unsetAll(keyPrefix) - if err != nil { - return gc.rmSection(keyPrefix) - } - - return nil -} - -func (gc *gitConfig) gitVersion() (*semver.Version, error) { - versionOut, err := gc.cli.runGitCommand("version") - if err != nil { - return nil, err - } - return parseGitVersion(versionOut) -} - -func parseGitVersion(versionOut string) (*semver.Version, error) { - // extract the version and truncate potential bad parts - // ex: 2.23.0.rc1 instead of 2.23.0-rc1 - r := regexp.MustCompile(`(\d+\.){1,2}\d+`) - - extracted := r.FindString(versionOut) - if extracted == "" { - return nil, fmt.Errorf("unreadable git version %s", versionOut) - } - - version, err := semver.Make(extracted) - if err != nil { - return nil, err - } - - return &version, nil -} - -func (gc *gitConfig) gitVersionLT218() (bool, error) { - version, err := gc.gitVersion() - if err != nil { - return false, err - } - - version218string := "2.18.0" - gitVersion218, err := semver.Make(version218string) - if err != nil { - return false, err - } - - return version.LT(gitVersion218), nil -} diff --git a/repository/git_test.go b/repository/git_test.go deleted file mode 100644 index 6603e339..00000000 --- a/repository/git_test.go +++ /dev/null @@ -1,6 +0,0 @@ -// Package repository contains helper methods for working with the Git repo. -package repository - -// func TestGitRepo(t *testing.T) { -// RepoTest(t, CreateTestRepo, CleanupTestRepos) -// } diff --git a/repository/git_testing.go b/repository/git_testing.go deleted file mode 100644 index 2168d53e..00000000 --- a/repository/git_testing.go +++ /dev/null @@ -1,72 +0,0 @@ -package repository - -import ( - "io/ioutil" - "log" - - "github.com/99designs/keyring" -) - -// This is intended for testing only - -func CreateTestRepo(bare bool) TestedRepo { - dir, err := ioutil.TempDir("", "") - if err != nil { - log.Fatal(err) - } - - var creator func(string) (*GitRepo, error) - - if bare { - creator = InitBareGitRepo - } else { - creator = InitGitRepo - } - - 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) - } - - // make sure we use a mock keyring for testing to not interact with the global system - return &replaceKeyring{ - TestedRepo: repo, - keyring: keyring.NewArrayKeyring(nil), - } -} - -func SetupReposAndRemote() (repoA, repoB, remote TestedRepo) { - repoA = CreateGoGitTestRepo(false) - repoB = CreateGoGitTestRepo(false) - remote = CreateGoGitTestRepo(true) - - err := repoA.AddRemote("origin", remote.GetLocalRemote()) - if err != nil { - log.Fatal(err) - } - - err = repoB.AddRemote("origin", remote.GetLocalRemote()) - if err != nil { - log.Fatal(err) - } - - return repoA, repoB, remote -} - -// replaceKeyring allow to replace the Keyring of the underlying repo -type replaceKeyring struct { - TestedRepo - keyring Keyring -} - -func (rk replaceKeyring) Keyring() Keyring { - return rk.keyring -} diff --git a/repository/gogit.go b/repository/gogit.go index 64ccb773..d6eb8621 100644 --- a/repository/gogit.go +++ b/repository/gogit.go @@ -20,6 +20,7 @@ import ( "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" + "golang.org/x/crypto/openpgp" "github.com/MichaelMure/git-bug/util/lamport" ) @@ -521,12 +522,13 @@ func (repo *GoGitRepo) ReadTree(hash Hash) ([]TreeEntry, error) { } // StoreCommit will store a Git commit with the given Git tree -func (repo *GoGitRepo) StoreCommit(treeHash Hash) (Hash, error) { - return repo.StoreCommitWithParent(treeHash, "") +func (repo *GoGitRepo) StoreCommit(treeHash Hash, parents ...Hash) (Hash, error) { + return repo.StoreSignedCommit(treeHash, nil, parents...) } -// StoreCommit will store a Git commit with the given Git tree -func (repo *GoGitRepo) StoreCommitWithParent(treeHash Hash, parent Hash) (Hash, error) { +// StoreCommit will store a Git commit with the given Git tree. If signKey is not nil, the commit +// will be signed accordingly. +func (repo *GoGitRepo) StoreSignedCommit(treeHash Hash, signKey *openpgp.Entity, parents ...Hash) (Hash, error) { cfg, err := repo.r.Config() if err != nil { return "", err @@ -547,8 +549,28 @@ func (repo *GoGitRepo) StoreCommitWithParent(treeHash Hash, parent Hash) (Hash, TreeHash: plumbing.NewHash(treeHash.String()), } - if parent != "" { - commit.ParentHashes = []plumbing.Hash{plumbing.NewHash(parent.String())} + for _, parent := range parents { + commit.ParentHashes = append(commit.ParentHashes, plumbing.NewHash(parent.String())) + } + + // Compute the signature if needed + if signKey != nil { + // first get the serialized commit + encoded := &plumbing.MemoryObject{} + if err := commit.Encode(encoded); err != nil { + return "", err + } + r, err := encoded.Reader() + if err != nil { + return "", err + } + + // sign the data + var sig bytes.Buffer + if err := openpgp.ArmoredDetachSign(&sig, signKey, r, nil); err != nil { + return "", err + } + commit.PGPSignature = sig.String() } obj := repo.r.Storer.NewEncodedObject() @@ -608,6 +630,13 @@ func (repo *GoGitRepo) UpdateRef(ref string, hash Hash) error { return repo.r.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(ref), plumbing.NewHash(hash.String()))) } +// MergeRef merge other into ref and update the reference +// If the update is not fast-forward, the callback treeHashFn will be called for the caller to generate +// the Tree to store in the merge commit. +func (repo *GoGitRepo) MergeRef(ref string, otherRef string, treeHashFn func() Hash) error { + return nonNativeMerge(repo, ref, otherRef, treeHashFn) +} + // RemoveRef will remove a Git reference func (repo *GoGitRepo) RemoveRef(ref string) error { return repo.r.Storer.RemoveReference(plumbing.ReferenceName(ref)) @@ -657,38 +686,16 @@ func (repo *GoGitRepo) CopyRef(source string, dest string) error { // ListCommits will return the list of tree hashes of a ref, in chronological order func (repo *GoGitRepo) ListCommits(ref string) ([]Hash, error) { - r, err := repo.r.Reference(plumbing.ReferenceName(ref), false) - if err != nil { - return nil, err - } + return nonNativeListCommits(repo, ref) +} - commit, err := repo.r.CommitObject(r.Hash()) +func (repo *GoGitRepo) ReadCommit(hash Hash) (Commit, error) { + encoded, err := repo.r.Storer.EncodedObject(plumbing.CommitObject, plumbing.NewHash(hash.String())) if err != nil { - return nil, err - } - hashes := []Hash{Hash(commit.Hash.String())} - - for { - commit, err = commit.Parent(0) - if err == object.ErrParentNotFound { - break - } - if err != nil { - return nil, err - } - - if commit.NumParents() > 1 { - return nil, fmt.Errorf("multiple parents") - } - - hashes = append([]Hash{Hash(commit.Hash.String())}, hashes...) + return Commit{}, err } - return hashes, nil -} - -func (repo *GoGitRepo) ReadCommit(hash Hash) (Commit, error) { - commit, err := repo.r.CommitObject(plumbing.NewHash(hash.String())) + commit, err := object.DecodeCommit(repo.r.Storer, encoded) if err != nil { return Commit{}, err } @@ -698,12 +705,25 @@ func (repo *GoGitRepo) ReadCommit(hash Hash) (Commit, error) { parents[i] = Hash(parentHash.String()) } - return Commit{ + result := Commit{ Hash: hash, Parents: parents, TreeHash: Hash(commit.TreeHash.String()), - }, nil + } + + if commit.PGPSignature != "" { + result.SignedData, err = encoded.Reader() + if err != nil { + return Commit{}, err + } + result.Signature, err = deArmorSignature(strings.NewReader(commit.PGPSignature)) + if err != nil { + return Commit{}, err + } + } + + return result, nil } func (repo *GoGitRepo) AllClocks() (map[string]lamport.Clock, error) { diff --git a/repository/gogit_testing.go b/repository/gogit_testing.go index a8bff41e..cad776b3 100644 --- a/repository/gogit_testing.go +++ b/repository/gogit_testing.go @@ -3,6 +3,8 @@ package repository import ( "io/ioutil" "log" + + "github.com/99designs/keyring" ) // This is intended for testing only @@ -34,7 +36,11 @@ func CreateGoGitTestRepo(bare bool) TestedRepo { log.Fatal("failed to set user.email for test repository: ", err) } - return repo + // make sure we use a mock keyring for testing to not interact with the global system + return &replaceKeyring{ + TestedRepo: repo, + keyring: keyring.NewArrayKeyring(nil), + } } func SetupGoGitReposAndRemote() (repoA, repoB, remote TestedRepo) { diff --git a/repository/keyring.go b/repository/keyring.go index 4cb3c9ff..64365c39 100644 --- a/repository/keyring.go +++ b/repository/keyring.go @@ -48,3 +48,13 @@ func defaultKeyring() (Keyring, error) { }, }) } + +// replaceKeyring allow to replace the Keyring of the underlying repo +type replaceKeyring struct { + TestedRepo + keyring Keyring +} + +func (rk replaceKeyring) Keyring() Keyring { + return rk.keyring +} diff --git a/repository/mock_repo.go b/repository/mock_repo.go index 227e0f2c..095ad61c 100644 --- a/repository/mock_repo.go +++ b/repository/mock_repo.go @@ -1,6 +1,7 @@ package repository import ( + "bytes" "crypto/sha1" "fmt" "strings" @@ -10,6 +11,7 @@ import ( "github.com/blevesearch/bleve" "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/memfs" + "golang.org/x/crypto/openpgp" "github.com/MichaelMure/git-bug/util/lamport" ) @@ -180,6 +182,7 @@ var _ RepoData = &mockRepoData{} type commit struct { treeHash Hash parents []Hash + sig string } type mockRepoData struct { @@ -198,12 +201,12 @@ func NewMockRepoData() *mockRepoData { } } -// PushRefs push git refs to a remote -func (r *mockRepoData) PushRefs(remote string, refSpec string) (string, error) { +func (r *mockRepoData) FetchRefs(remote string, refSpec string) (string, error) { return "", nil } -func (r *mockRepoData) FetchRefs(remote string, refSpec string) (string, error) { +// PushRefs push git refs to a remote +func (r *mockRepoData) PushRefs(remote string, refSpec string) (string, error) { return "", nil } @@ -216,7 +219,6 @@ func (r *mockRepoData) StoreData(data []byte) (Hash, error) { func (r *mockRepoData) ReadData(hash Hash) ([]byte, error) { data, ok := r.blobs[hash] - if !ok { return nil, fmt.Errorf("unknown hash") } @@ -233,25 +235,86 @@ func (r *mockRepoData) StoreTree(entries []TreeEntry) (Hash, error) { return hash, nil } -func (r *mockRepoData) StoreCommit(treeHash Hash) (Hash, error) { - rawHash := sha1.Sum([]byte(treeHash)) - hash := Hash(fmt.Sprintf("%x", rawHash)) - r.commits[hash] = commit{ - treeHash: treeHash, +func (r *mockRepoData) ReadTree(hash Hash) ([]TreeEntry, error) { + var data string + + data, ok := r.trees[hash] + + if !ok { + // Git will understand a commit hash to reach a tree + commit, ok := r.commits[hash] + + if !ok { + return nil, fmt.Errorf("unknown hash") + } + + data, ok = r.trees[commit.treeHash] + + if !ok { + return nil, fmt.Errorf("unknown hash") + } } - return hash, nil + + return readTreeEntries(data) +} + +func (r *mockRepoData) StoreCommit(treeHash Hash, parents ...Hash) (Hash, error) { + return r.StoreSignedCommit(treeHash, nil, parents...) } -func (r *mockRepoData) StoreCommitWithParent(treeHash Hash, parent Hash) (Hash, error) { - rawHash := sha1.Sum([]byte(treeHash + parent)) +func (r *mockRepoData) StoreSignedCommit(treeHash Hash, signKey *openpgp.Entity, parents ...Hash) (Hash, error) { + hasher := sha1.New() + hasher.Write([]byte(treeHash)) + for _, parent := range parents { + hasher.Write([]byte(parent)) + } + rawHash := hasher.Sum(nil) hash := Hash(fmt.Sprintf("%x", rawHash)) - r.commits[hash] = commit{ + c := commit{ treeHash: treeHash, - parents: []Hash{parent}, + parents: parents, + } + if signKey != nil { + // unlike go-git, we only sign the tree hash for simplicity instead of all the fields (parents ...) + var sig bytes.Buffer + if err := openpgp.DetachSign(&sig, signKey, strings.NewReader(string(treeHash)), nil); err != nil { + return "", err + } + c.sig = sig.String() } + r.commits[hash] = c return hash, nil } +func (r *mockRepoData) ReadCommit(hash Hash) (Commit, error) { + c, ok := r.commits[hash] + if !ok { + return Commit{}, fmt.Errorf("unknown commit") + } + + result := Commit{ + Hash: hash, + Parents: c.parents, + TreeHash: c.treeHash, + } + + if c.sig != "" { + result.SignedData = strings.NewReader(string(c.treeHash)) + result.Signature = strings.NewReader(c.sig) + } + + return result, nil +} + +func (r *mockRepoData) GetTreeHash(commit Hash) (Hash, error) { + c, ok := r.commits[commit] + if !ok { + return "", fmt.Errorf("unknown commit") + } + + return c.treeHash, nil +} + func (r *mockRepoData) ResolveRef(ref string) (Hash, error) { h, ok := r.refs[ref] if !ok { @@ -270,22 +333,6 @@ func (r *mockRepoData) RemoveRef(ref string) error { return nil } -func (r *mockRepoData) RefExist(ref string) (bool, error) { - _, exist := r.refs[ref] - return exist, nil -} - -func (r *mockRepoData) CopyRef(source string, dest string) error { - hash, exist := r.refs[source] - - if !exist { - return fmt.Errorf("Unknown ref") - } - - r.refs[dest] = hash - return nil -} - func (r *mockRepoData) ListRefs(refPrefix string) ([]string, error) { var keys []string @@ -298,63 +345,20 @@ func (r *mockRepoData) ListRefs(refPrefix string) ([]string, error) { return keys, nil } -func (r *mockRepoData) ListCommits(ref string) ([]Hash, error) { - var hashes []Hash - - hash := r.refs[ref] - - for { - commit, ok := r.commits[hash] - - if !ok { - break - } - - hashes = append([]Hash{hash}, hashes...) - - if len(commit.parents) == 0 { - break - } - hash = commit.parents[0] - } - - return hashes, nil -} - -func (r *mockRepoData) ReadCommit(hash Hash) (Commit, error) { - c, ok := r.commits[hash] - if !ok { - return Commit{}, fmt.Errorf("unknown commit") - } - - return Commit{ - Hash: hash, - Parents: c.parents, - TreeHash: c.treeHash, - }, nil +func (r *mockRepoData) RefExist(ref string) (bool, error) { + _, exist := r.refs[ref] + return exist, nil } -func (r *mockRepoData) ReadTree(hash Hash) ([]TreeEntry, error) { - var data string - - data, ok := r.trees[hash] - - if !ok { - // Git will understand a commit hash to reach a tree - commit, ok := r.commits[hash] - - if !ok { - return nil, fmt.Errorf("unknown hash") - } - - data, ok = r.trees[commit.treeHash] +func (r *mockRepoData) CopyRef(source string, dest string) error { + hash, exist := r.refs[source] - if !ok { - return nil, fmt.Errorf("unknown hash") - } + if !exist { + return fmt.Errorf("Unknown ref") } - return readTreeEntries(data) + r.refs[dest] = hash + return nil } func (r *mockRepoData) FindCommonAncestor(hash1 Hash, hash2 Hash) (Hash, error) { @@ -392,13 +396,8 @@ func (r *mockRepoData) FindCommonAncestor(hash1 Hash, hash2 Hash) (Hash, error) } } -func (r *mockRepoData) GetTreeHash(commit Hash) (Hash, error) { - c, ok := r.commits[commit] - if !ok { - return "", fmt.Errorf("unknown commit") - } - - return c.treeHash, nil +func (r *mockRepoData) ListCommits(ref string) ([]Hash, error) { + return nonNativeListCommits(r, ref) } var _ RepoClock = &mockRepoClock{} diff --git a/repository/mock_repo_test.go b/repository/mock_repo_test.go index dec09380..12851a80 100644 --- a/repository/mock_repo_test.go +++ b/repository/mock_repo_test.go @@ -1,6 +1,8 @@ package repository -import "testing" +import ( + "testing" +) func TestMockRepo(t *testing.T) { creator := func(bare bool) TestedRepo { return NewMockRepo() } diff --git a/repository/repo.go b/repository/repo.go index afd8ff77..d7afa983 100644 --- a/repository/repo.go +++ b/repository/repo.go @@ -3,15 +3,17 @@ package repository import ( "errors" + "io" "github.com/blevesearch/bleve" "github.com/go-git/go-billy/v5" + "golang.org/x/crypto/openpgp" "github.com/MichaelMure/git-bug/util/lamport" ) var ( - // ErrNotARepo is the error returned when the git repo root wan't be found + // ErrNotARepo is the error returned when the git repo root can't be found ErrNotARepo = errors.New("not a git repository") // ErrClockNotExist is the error returned when a clock can't be found ErrClockNotExist = errors.New("clock doesn't exist") @@ -89,9 +91,11 @@ type RepoBleve interface { } type Commit struct { - Hash Hash - Parents []Hash - TreeHash Hash + Hash Hash + Parents []Hash // hashes of the parents, if any + TreeHash Hash // hash of the git Tree + SignedData io.Reader // if signed, reader for the signed data (likely, the serialized commit) + Signature io.Reader // if signed, reader for the (non-armored) signature } // RepoData give access to the git data storage @@ -116,21 +120,29 @@ type RepoData interface { ReadTree(hash Hash) ([]TreeEntry, error) // StoreCommit will store a Git commit with the given Git tree - StoreCommit(treeHash Hash) (Hash, error) + StoreCommit(treeHash Hash, parents ...Hash) (Hash, error) - // StoreCommit will store a Git commit with the given Git tree - StoreCommitWithParent(treeHash Hash, parent Hash) (Hash, error) + // StoreCommit will store a Git commit with the given Git tree. If signKey is not nil, the commit + // will be signed accordingly. + StoreSignedCommit(treeHash Hash, signKey *openpgp.Entity, parents ...Hash) (Hash, error) + // ReadCommit read a Git commit and returns some of its characteristic ReadCommit(hash Hash) (Commit, error) // GetTreeHash return the git tree hash referenced in a commit GetTreeHash(commit Hash) (Hash, error) + // ResolveRef returns the hash of the target commit of the given ref ResolveRef(ref string) (Hash, error) // UpdateRef will create or update a Git reference UpdateRef(ref string, hash Hash) error + // // MergeRef merge other into ref and update the reference + // // If the update is not fast-forward, the callback treeHashFn will be called for the caller to generate + // // the Tree to store in the merge commit. + // MergeRef(ref string, otherRef string, treeHashFn func() Hash) error + // RemoveRef will remove a Git reference RemoveRef(ref string) error @@ -148,7 +160,6 @@ type RepoData interface { FindCommonAncestor(commit1 Hash, commit2 Hash) (Hash, error) // ListCommits will return the list of tree hashes of a ref, in chronological order - // Deprecated ListCommits(ref string) ([]Hash, error) } diff --git a/repository/repo_testing.go b/repository/repo_testing.go index 1d3a3155..4a5c48bb 100644 --- a/repository/repo_testing.go +++ b/repository/repo_testing.go @@ -135,7 +135,8 @@ func RepoDataTest(t *testing.T, repo RepoData) { require.NoError(t, err) require.Equal(t, treeHash1, treeHash1Read) - commit2, err := repo.StoreCommitWithParent(treeHash2, commit1) + // commit with a parent + commit2, err := repo.StoreCommit(treeHash2, commit1) require.NoError(t, err) require.True(t, commit2.IsValid()) @@ -187,7 +188,7 @@ func RepoDataTest(t *testing.T, repo RepoData) { // Graph - commit3, err := repo.StoreCommitWithParent(treeHash1, commit1) + commit3, err := repo.StoreCommit(treeHash1, commit1) require.NoError(t, err) ancestorHash, err := repo.FindCommonAncestor(commit2, commit3) @@ -237,3 +238,22 @@ func randomData() []byte { } return b } + +func makeCommit(t *testing.T, repo RepoData, parents ...Hash) Hash { + blobHash, err := repo.StoreData(randomData()) + require.NoError(t, err) + + treeHash, err := repo.StoreTree([]TreeEntry{ + { + ObjectType: Blob, + Hash: blobHash, + Name: "foo", + }, + }) + require.NoError(t, err) + + commitHash, err := repo.StoreCommit(treeHash, parents...) + require.NoError(t, err) + + return commitHash +} -- cgit