diff options
Diffstat (limited to 'repository')
-rw-r--r-- | repository/common.go | 67 | ||||
-rw-r--r-- | repository/git.go | 500 | ||||
-rw-r--r-- | repository/git_cli.go | 57 | ||||
-rw-r--r-- | repository/git_config.go | 221 | ||||
-rw-r--r-- | repository/git_test.go | 10 | ||||
-rw-r--r-- | repository/git_testing.go | 72 | ||||
-rw-r--r-- | repository/gogit.go | 187 | ||||
-rw-r--r-- | repository/gogit_testing.go | 8 | ||||
-rw-r--r-- | repository/keyring.go | 12 | ||||
-rw-r--r-- | repository/mock_repo.go | 257 | ||||
-rw-r--r-- | repository/mock_repo_test.go | 6 | ||||
-rw-r--r-- | repository/repo.go | 58 | ||||
-rw-r--r-- | repository/repo_testing.go | 84 | ||||
-rw-r--r-- | repository/tree_entry.go | 10 |
14 files changed, 543 insertions, 1006 deletions
diff --git a/repository/common.go b/repository/common.go new file mode 100644 index 00000000..4cefbd9e --- /dev/null +++ b/repository/common.go @@ -0,0 +1,67 @@ +package repository + +import ( + "io" + + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/armor" + "golang.org/x/crypto/openpgp/errors" +) + +// 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 bc9d8772..00000000 --- a/repository/git.go +++ /dev/null @@ -1,500 +0,0 @@ -// Package repository contains helper methods for working with the Git repo. -package repository - -import ( - "bytes" - "fmt" - "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" -) - -const ( - clockPath = "git-bug" -) - -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 -} - -// 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 - - 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) { - repo := &GitRepo{ - gitCli: gitCli{path: path}, - path: path + "/.git", - clocks: make(map[string]lamport.Clock), - indexes: make(map[string]bleve.Index), - } - - _, 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) { - repo := &GitRepo{ - gitCli: gitCli{path: path}, - path: path, - clocks: make(map[string]lamport.Clock), - indexes: make(map[string]bleve.Index), - } - - _, 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 osfs.New(repo.path) -} - -// 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 -} - -// 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(), name+"-clock") - 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(), name+"-clock") - 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 *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 21cc40e2..00000000 --- a/repository/git_cli.go +++ /dev/null @@ -1,57 +0,0 @@ -package repository - -import ( - "bytes" - "fmt" - "io" - "strings" - - "golang.org/x/sys/execabs" -) - -// 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 := execabs.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 1b36fd4c..00000000 --- a/repository/git_test.go +++ /dev/null @@ -1,10 +0,0 @@ -// Package repository contains helper methods for working with the Git repo. -package repository - -import ( - "testing" -) - -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 f2d2b57e..248c34d5 100644 --- a/repository/gogit.go +++ b/repository/gogit.go @@ -19,11 +19,14 @@ 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" "golang.org/x/sys/execabs" "github.com/MichaelMure/git-bug/util/lamport" ) +const clockPath = "clocks" + var _ ClockedRepo = &GoGitRepo{} var _ TestedRepo = &GoGitRepo{} @@ -350,13 +353,17 @@ func (repo *GoGitRepo) ClearBleveIndex(name string) error { return nil } -// FetchRefs fetch git refs from a remote -func (repo *GoGitRepo) FetchRefs(remote string, refSpec string) (string, error) { +// FetchRefs fetch git refs matching a directory prefix to a remote +// Ex: prefix="foo" will fetch any remote refs matching "refs/foo/*" locally. +// The equivalent git refspec would be "refs/foo/*:refs/remotes/<remote>/foo/*" +func (repo *GoGitRepo) FetchRefs(remote string, prefix string) (string, error) { + refspec := fmt.Sprintf("refs/%s/*:refs/remotes/%s/%s/*", prefix, remote, prefix) + buf := bytes.NewBuffer(nil) err := repo.r.Fetch(&gogit.FetchOptions{ RemoteName: remote, - RefSpecs: []config.RefSpec{config.RefSpec(refSpec)}, + RefSpecs: []config.RefSpec{config.RefSpec(refspec)}, Progress: buf, }) if err == gogit.NoErrAlreadyUpToDate { @@ -369,13 +376,41 @@ func (repo *GoGitRepo) FetchRefs(remote string, refSpec string) (string, error) return buf.String(), nil } -// PushRefs push git refs to a remote -func (repo *GoGitRepo) PushRefs(remote string, refSpec string) (string, error) { +// PushRefs push git refs matching a directory prefix to a remote +// Ex: prefix="foo" will push any local refs matching "refs/foo/*" to the remote. +// The equivalent git refspec would be "refs/foo/*:refs/foo/*" +// +// Additionally, PushRefs will update the local references in refs/remotes/<remote>/foo to match +// the remote state. +func (repo *GoGitRepo) PushRefs(remote string, prefix string) (string, error) { + refspec := fmt.Sprintf("refs/%s/*:refs/%s/*", prefix, prefix) + + remo, err := repo.r.Remote(remote) + if err != nil { + return "", err + } + + // to make sure that the push also create the corresponding refs/remotes/<remote>/... references, + // we need to have a default fetch refspec configured on the remote, to make our refs "track" the remote ones. + // This does not change the config on disk, only on memory. + hasCustomFetch := false + fetchRefspec := fmt.Sprintf("refs/%s/*:refs/remotes/%s/%s/*", prefix, remote, prefix) + for _, r := range remo.Config().Fetch { + if string(r) == fetchRefspec { + hasCustomFetch = true + break + } + } + + if !hasCustomFetch { + remo.Config().Fetch = append(remo.Config().Fetch, config.RefSpec(fetchRefspec)) + } + buf := bytes.NewBuffer(nil) - err := repo.r.Push(&gogit.PushOptions{ + err = remo.Push(&gogit.PushOptions{ RemoteName: remote, - RefSpecs: []config.RefSpec{config.RefSpec(refSpec)}, + RefSpecs: []config.RefSpec{config.RefSpec(refspec)}, Progress: buf, }) if err == gogit.NoErrAlreadyUpToDate { @@ -519,12 +554,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 @@ -545,8 +581,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() @@ -593,6 +649,14 @@ func (repo *GoGitRepo) FindCommonAncestor(commit1 Hash, commit2 Hash) (Hash, err return Hash(commits[0].Hash.String()), nil } +func (repo *GoGitRepo) ResolveRef(ref string) (Hash, error) { + r, err := repo.r.Reference(plumbing.ReferenceName(ref), false) + if err != nil { + return "", err + } + return Hash(r.Hash().String()), nil +} + // UpdateRef will create or update a Git reference func (repo *GoGitRepo) UpdateRef(ref string, hash Hash) error { return repo.r.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(ref), plumbing.NewHash(hash.String()))) @@ -647,34 +711,79 @@ 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) + return nonNativeListCommits(repo, ref) +} + +func (repo *GoGitRepo) ReadCommit(hash Hash) (Commit, error) { + commit, err := repo.r.CommitObject(plumbing.NewHash(hash.String())) if err != nil { - return nil, err + return Commit{}, err } - commit, err := repo.r.CommitObject(r.Hash()) - if err != nil { - return nil, err + parents := make([]Hash, len(commit.ParentHashes)) + for i, parentHash := range commit.ParentHashes { + parents[i] = Hash(parentHash.String()) } - hashes := []Hash{Hash(commit.Hash.String())} - for { - commit, err = commit.Parent(0) - if err == object.ErrParentNotFound { - break + result := Commit{ + Hash: hash, + Parents: parents, + TreeHash: Hash(commit.TreeHash.String()), + } + + if commit.PGPSignature != "" { + // I can't find a way to just remove the signature when reading the encoded commit so we need to + // re-encode the commit without signature. + + encoded := &plumbing.MemoryObject{} + err := commit.EncodeWithoutSignature(encoded) + if err != nil { + return Commit{}, err } + + result.SignedData, err = encoded.Reader() if err != nil { - return nil, err + return Commit{}, err } - if commit.NumParents() > 1 { - return nil, fmt.Errorf("multiple parents") + 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) { + 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 + } - hashes = append([]Hash{Hash(commit.Hash.String())}, hashes...) + 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 hashes, nil + return result, nil } // GetOrCreateClock return a Lamport clock stored in the Repo. @@ -691,7 +800,7 @@ func (repo *GoGitRepo) GetOrCreateClock(name string) (lamport.Clock, error) { return nil, err } - c, err = lamport.NewPersistedClock(repo.localStorage, name+"-clock") + c, err = lamport.NewPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name)) if err != nil { return nil, err } @@ -705,7 +814,7 @@ func (repo *GoGitRepo) getClock(name string) (lamport.Clock, error) { return c, nil } - c, err := lamport.LoadPersistedClock(repo.localStorage, name+"-clock") + c, err := lamport.LoadPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name)) if err == nil { repo.clocks[name] = c return c, nil @@ -716,6 +825,24 @@ func (repo *GoGitRepo) getClock(name string) (lamport.Clock, error) { return nil, err } +// Increment is equivalent to c = GetOrCreateClock(name) + c.Increment() +func (repo *GoGitRepo) 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 *GoGitRepo) 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 *GoGitRepo) AddRemote(name string, url string) 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..6cba303e 100644 --- a/repository/keyring.go +++ b/repository/keyring.go @@ -15,7 +15,7 @@ var ErrKeyringKeyNotFound = keyring.ErrKeyNotFound type Keyring interface { // Returns an Item matching the key or ErrKeyringKeyNotFound Get(key string) (Item, error) - // Stores an Item on the keyring + // Stores an Item on the keyring. Set is idempotent. Set(item Item) error // Removes the item with matching key Remove(key string) error @@ -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 8a1724ef..2749bfbd 100644 --- a/repository/mock_repo.go +++ b/repository/mock_repo.go @@ -1,6 +1,7 @@ package repository import ( + "bytes" "crypto/sha1" "fmt" "strings" @@ -10,15 +11,16 @@ 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" ) -var _ ClockedRepo = &mockRepoForTest{} -var _ TestedRepo = &mockRepoForTest{} +var _ ClockedRepo = &mockRepo{} +var _ TestedRepo = &mockRepo{} -// mockRepoForTest defines an instance of Repo that can be used for testing. -type mockRepoForTest struct { +// mockRepo defines an instance of Repo that can be used for testing. +type mockRepo struct { *mockRepoConfig *mockRepoKeyring *mockRepoCommon @@ -26,12 +28,13 @@ type mockRepoForTest struct { *mockRepoBleve *mockRepoData *mockRepoClock + *mockRepoTest } -func (m *mockRepoForTest) Close() error { return nil } +func (m *mockRepo) Close() error { return nil } -func NewMockRepoForTest() *mockRepoForTest { - return &mockRepoForTest{ +func NewMockRepo() *mockRepo { + return &mockRepo{ mockRepoConfig: NewMockRepoConfig(), mockRepoKeyring: NewMockRepoKeyring(), mockRepoCommon: NewMockRepoCommon(), @@ -39,6 +42,7 @@ func NewMockRepoForTest() *mockRepoForTest { mockRepoBleve: newMockRepoBleve(), mockRepoData: NewMockRepoData(), mockRepoClock: NewMockRepoClock(), + mockRepoTest: NewMockRepoTest(), } } @@ -177,7 +181,8 @@ var _ RepoData = &mockRepoData{} type commit struct { treeHash Hash - parent Hash + parents []Hash + sig string } type mockRepoData struct { @@ -196,13 +201,13 @@ func NewMockRepoData() *mockRepoData { } } -// PushRefs push git refs to a remote -func (r *mockRepoData) PushRefs(remote string, refSpec string) (string, error) { - return "", nil +func (r *mockRepoData) FetchRefs(remote string, prefix string) (string, error) { + panic("implement me") } -func (r *mockRepoData) FetchRefs(remote string, refSpec string) (string, error) { - return "", nil +// PushRefs push git refs to a remote +func (r *mockRepoData) PushRefs(remote string, prefix string) (string, error) { + panic("implement me") } func (r *mockRepoData) StoreData(data []byte) (Hash, error) { @@ -214,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") } @@ -231,48 +235,103 @@ 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, - parent: 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) UpdateRef(ref string, hash Hash) error { - r.refs[ref] = hash - return nil -} +func (r *mockRepoData) ReadCommit(hash Hash) (Commit, error) { + c, ok := r.commits[hash] + if !ok { + return Commit{}, fmt.Errorf("unknown commit") + } -func (r *mockRepoData) RemoveRef(ref string) error { - delete(r.refs, ref) - return nil -} + result := Commit{ + Hash: hash, + Parents: c.parents, + TreeHash: c.treeHash, + } -func (r *mockRepoData) RefExist(ref string) (bool, error) { - _, exist := r.refs[ref] - return exist, nil + if c.sig != "" { + // Note: this is actually incorrect as the signed data should be the full commit (+comment, +date ...) + // but only the tree hash work for our purpose here. + result.SignedData = strings.NewReader(string(c.treeHash)) + result.Signature = strings.NewReader(c.sig) + } + + return result, nil } -func (r *mockRepoData) CopyRef(source string, dest string) error { - hash, exist := r.refs[source] +func (r *mockRepoData) GetTreeHash(commit Hash) (Hash, error) { + c, ok := r.commits[commit] + if !ok { + return "", fmt.Errorf("unknown commit") + } - if !exist { - return fmt.Errorf("Unknown ref") + return c.treeHash, nil +} + +func (r *mockRepoData) ResolveRef(ref string) (Hash, error) { + h, ok := r.refs[ref] + if !ok { + return "", fmt.Errorf("unknown ref") } + return h, nil +} - r.refs[dest] = hash +func (r *mockRepoData) UpdateRef(ref string, hash Hash) error { + r.refs[ref] = hash + return nil +} + +func (r *mockRepoData) RemoveRef(ref string) error { + delete(r.refs, ref) return nil } @@ -288,46 +347,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...) - hash = commit.parent - } - - return hashes, 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) { @@ -338,8 +371,11 @@ func (r *mockRepoData) FindCommonAncestor(hash1 Hash, hash2 Hash) (Hash, error) if !ok { return "", fmt.Errorf("unknown commit %v", hash1) } - ancestor1 = append(ancestor1, c.parent) - hash1 = c.parent + if len(c.parents) == 0 { + break + } + ancestor1 = append(ancestor1, c.parents[0]) + hash1 = c.parents[0] } for { @@ -354,35 +390,19 @@ func (r *mockRepoData) FindCommonAncestor(hash1 Hash, hash2 Hash) (Hash, error) return "", fmt.Errorf("unknown commit %v", hash1) } - if c.parent == "" { + if c.parents[0] == "" { return "", fmt.Errorf("no ancestor found") } - hash2 = c.parent - } -} - -func (r *mockRepoData) GetTreeHash(commit Hash) (Hash, error) { - c, ok := r.commits[commit] - if !ok { - return "", fmt.Errorf("unknown commit") + hash2 = c.parents[0] } - - return c.treeHash, nil } -func (r *mockRepoData) AddRemote(name string, url string) error { - panic("implement me") -} - -func (m mockRepoForTest) GetLocalRemote() string { - panic("implement me") +func (r *mockRepoData) ListCommits(ref string) ([]Hash, error) { + return nonNativeListCommits(r, ref) } -func (m mockRepoForTest) EraseFromDisk() error { - // nothing to do - return nil -} +var _ RepoClock = &mockRepoClock{} type mockRepoClock struct { mu sync.Mutex @@ -395,6 +415,10 @@ func NewMockRepoClock() *mockRepoClock { } } +func (r *mockRepoClock) AllClocks() (map[string]lamport.Clock, error) { + return r.clocks, nil +} + func (r *mockRepoClock) GetOrCreateClock(name string) (lamport.Clock, error) { r.mu.Lock() defer r.mu.Unlock() @@ -407,3 +431,40 @@ func (r *mockRepoClock) GetOrCreateClock(name string) (lamport.Clock, error) { r.clocks[name] = c return c, nil } + +func (r *mockRepoClock) Increment(name string) (lamport.Time, error) { + c, err := r.GetOrCreateClock(name) + if err != nil { + return lamport.Time(0), err + } + return c.Increment() +} + +func (r *mockRepoClock) Witness(name string, time lamport.Time) error { + c, err := r.GetOrCreateClock(name) + if err != nil { + return err + } + return c.Witness(time) +} + +var _ repoTest = &mockRepoTest{} + +type mockRepoTest struct{} + +func NewMockRepoTest() *mockRepoTest { + return &mockRepoTest{} +} + +func (r *mockRepoTest) AddRemote(name string, url string) error { + panic("implement me") +} + +func (r mockRepoTest) GetLocalRemote() string { + panic("implement me") +} + +func (r mockRepoTest) EraseFromDisk() error { + // nothing to do + return nil +} diff --git a/repository/mock_repo_test.go b/repository/mock_repo_test.go index b56b94f2..12851a80 100644 --- a/repository/mock_repo_test.go +++ b/repository/mock_repo_test.go @@ -1,9 +1,11 @@ package repository -import "testing" +import ( + "testing" +) func TestMockRepo(t *testing.T) { - creator := func(bare bool) TestedRepo { return NewMockRepoForTest() } + creator := func(bare bool) TestedRepo { return NewMockRepo() } cleaner := func(repos ...Repo) {} RepoTest(t, creator, cleaner) diff --git a/repository/repo.go b/repository/repo.go index a1dc129e..80bb7ce7 100644 --- a/repository/repo.go +++ b/repository/repo.go @@ -3,9 +3,11 @@ 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" ) @@ -22,9 +24,9 @@ type Repo interface { RepoConfig RepoKeyring RepoCommon - RepoData RepoStorage RepoBleve + RepoData Close() error } @@ -88,13 +90,28 @@ type RepoBleve interface { ClearBleveIndex(name string) error } +type Commit struct { + 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 type RepoData interface { - // FetchRefs fetch git refs from a remote - FetchRefs(remote string, refSpec string) (string, error) - - // PushRefs push git refs to a remote - PushRefs(remote string, refSpec string) (string, error) + // FetchRefs fetch git refs matching a directory prefix to a remote + // Ex: prefix="foo" will fetch any remote refs matching "refs/foo/*" locally. + // The equivalent git refspec would be "refs/foo/*:refs/remotes/<remote>/foo/*" + FetchRefs(remote string, prefix string) (string, error) + + // PushRefs push git refs matching a directory prefix to a remote + // Ex: prefix="foo" will push any local refs matching "refs/foo/*" to the remote. + // The equivalent git refspec would be "refs/foo/*:refs/foo/*" + // + // Additionally, PushRefs will update the local references in refs/remotes/<remote>/foo to match + // the remote state. + PushRefs(remote string, prefix string) (string, error) // StoreData will store arbitrary data and return the corresponding hash StoreData(data []byte) (Hash, error) @@ -110,21 +127,27 @@ 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 + // Deprecated GetTreeHash(commit Hash) (Hash, error) - // FindCommonAncestor will return the last common ancestor of two chain of commit - FindCommonAncestor(commit1 Hash, commit2 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 // RemoveRef will remove a Git reference + // RemoveRef is idempotent. RemoveRef(ref string) error // ListRefs will return a list of Git ref matching the given refspec @@ -136,15 +159,28 @@ type RepoData interface { // CopyRef will create a new reference with the same value as another one CopyRef(source string, dest string) error + // FindCommonAncestor will return the last common ancestor of two chain of commit + // Deprecated + FindCommonAncestor(commit1 Hash, commit2 Hash) (Hash, error) + // ListCommits will return the list of tree hashes of a ref, in chronological order ListCommits(ref string) ([]Hash, error) } // RepoClock give access to Lamport clocks type RepoClock interface { + // AllClocks return all the known clocks + AllClocks() (map[string]lamport.Clock, error) + // GetOrCreateClock return a Lamport clock stored in the Repo. // If the clock doesn't exist, it's created. GetOrCreateClock(name string) (lamport.Clock, error) + + // Increment is equivalent to c = GetOrCreateClock(name) + c.Increment() + Increment(name string) (lamport.Time, error) + + // Witness is equivalent to c = GetOrCreateClock(name) + c.Witness(time) + Witness(name string, time lamport.Time) error } // ClockLoader hold which logical clock need to exist for an entity and diff --git a/repository/repo_testing.go b/repository/repo_testing.go index c0e1fa79..1f80d898 100644 --- a/repository/repo_testing.go +++ b/repository/repo_testing.go @@ -6,10 +6,14 @@ import ( "testing" "github.com/stretchr/testify/require" + "golang.org/x/crypto/openpgp" "github.com/MichaelMure/git-bug/util/lamport" ) +// TODO: add tests for RepoBleve +// TODO: add tests for RepoStorage + func CleanupTestRepos(repos ...Repo) { var firstErr error for _, repo := range repos { @@ -44,6 +48,7 @@ func RepoTest(t *testing.T, creator RepoCreator, cleaner RepoCleaner) { t.Run("Data", func(t *testing.T) { RepoDataTest(t, repo) + RepoDataSignatureTest(t, repo) }) t.Run("Config", func(t *testing.T) { @@ -135,7 +140,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()) @@ -148,6 +154,11 @@ func RepoDataTest(t *testing.T, repo RepoData) { require.NoError(t, err) require.Equal(t, tree1read, tree1) + c2, err := repo.ReadCommit(commit2) + require.NoError(t, err) + c2expected := Commit{Hash: commit2, Parents: []Hash{commit1}, TreeHash: treeHash2} + require.Equal(t, c2expected, c2) + // Ref exist1, err := repo.RefExist("refs/bugs/ref1") @@ -161,6 +172,10 @@ func RepoDataTest(t *testing.T, repo RepoData) { require.NoError(t, err) require.True(t, exist1) + h, err := repo.ResolveRef("refs/bugs/ref1") + require.NoError(t, err) + require.Equal(t, commit2, h) + ls, err := repo.ListRefs("refs/bugs") require.NoError(t, err) require.ElementsMatch(t, []string{"refs/bugs/ref1"}, ls) @@ -178,7 +193,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) @@ -187,17 +202,73 @@ func RepoDataTest(t *testing.T, repo RepoData) { err = repo.RemoveRef("refs/bugs/ref1") require.NoError(t, err) + + // RemoveRef is idempotent + err = repo.RemoveRef("refs/bugs/ref1") + require.NoError(t, err) +} + +func RepoDataSignatureTest(t *testing.T, repo RepoData) { + data := randomData() + + blobHash, err := repo.StoreData(data) + require.NoError(t, err) + + treeHash, err := repo.StoreTree([]TreeEntry{ + { + ObjectType: Blob, + Hash: blobHash, + Name: "blob", + }, + }) + require.NoError(t, err) + + pgpEntity1, err := openpgp.NewEntity("", "", "", nil) + require.NoError(t, err) + keyring1 := openpgp.EntityList{pgpEntity1} + + pgpEntity2, err := openpgp.NewEntity("", "", "", nil) + require.NoError(t, err) + keyring2 := openpgp.EntityList{pgpEntity2} + + commitHash1, err := repo.StoreSignedCommit(treeHash, pgpEntity1) + require.NoError(t, err) + + commit1, err := repo.ReadCommit(commitHash1) + require.NoError(t, err) + + _, err = openpgp.CheckDetachedSignature(keyring1, commit1.SignedData, commit1.Signature) + require.NoError(t, err) + + _, err = openpgp.CheckDetachedSignature(keyring2, commit1.SignedData, commit1.Signature) + require.Error(t, err) + + commitHash2, err := repo.StoreSignedCommit(treeHash, pgpEntity1, commitHash1) + require.NoError(t, err) + + commit2, err := repo.ReadCommit(commitHash2) + require.NoError(t, err) + + _, err = openpgp.CheckDetachedSignature(keyring1, commit2.SignedData, commit2.Signature) + require.NoError(t, err) + + _, err = openpgp.CheckDetachedSignature(keyring2, commit2.SignedData, commit2.Signature) + require.Error(t, err) } // helper to test a RepoClock func RepoClockTest(t *testing.T, repo RepoClock) { + allClocks, err := repo.AllClocks() + require.NoError(t, err) + require.Len(t, allClocks, 0) + clock, err := repo.GetOrCreateClock("foo") require.NoError(t, err) require.Equal(t, lamport.Time(1), clock.Time()) time, err := clock.Increment() require.NoError(t, err) - require.Equal(t, lamport.Time(1), time) + require.Equal(t, lamport.Time(2), time) require.Equal(t, lamport.Time(2), clock.Time()) clock2, err := repo.GetOrCreateClock("foo") @@ -207,6 +278,13 @@ func RepoClockTest(t *testing.T, repo RepoClock) { clock3, err := repo.GetOrCreateClock("bar") require.NoError(t, err) require.Equal(t, lamport.Time(1), clock3.Time()) + + allClocks, err = repo.AllClocks() + require.NoError(t, err) + require.Equal(t, map[string]lamport.Clock{ + "foo": clock, + "bar": clock3, + }, allClocks) } func randomData() []byte { diff --git a/repository/tree_entry.go b/repository/tree_entry.go index 6c5ec1a5..9d70814c 100644 --- a/repository/tree_entry.go +++ b/repository/tree_entry.go @@ -100,3 +100,13 @@ func readTreeEntries(s string) ([]TreeEntry, error) { return casted, nil } + +// SearchTreeEntry search a TreeEntry by name from an array +func SearchTreeEntry(entries []TreeEntry, name string) (TreeEntry, bool) { + for _, entry := range entries { + if entry.Name == name { + return entry, true + } + } + return TreeEntry{}, false +} |