repo: implement local/global/any config everywhere

Michael Muré created

Change summary

cache/repo_cache.go        |   2 
cache/repo_cache_common.go |  10 +
commands/webui.go          |   2 
repository/config.go       |  64 ++++++++
repository/config_test.go  |  54 +++++++
repository/git.go          |  83 +++-------
repository/git_cli.go      |  56 +++++++
repository/git_config.go   |  18 +-
repository/gogit.go        |  25 +++
repository/mock_repo.go    |  11 +
repository/repo.go         |   6 
repository/repo_testing.go | 299 ++++++++++++++++++++-------------------
12 files changed, 413 insertions(+), 217 deletions(-)

Detailed changes

cache/repo_cache.go 🔗

@@ -25,6 +25,8 @@ const formatVersion = 2
 const defaultMaxLoadedBugs = 1000
 
 var _ repository.RepoCommon = &RepoCache{}
+var _ repository.RepoConfig = &RepoCache{}
+var _ repository.RepoKeyring = &RepoCache{}
 
 // RepoCache is a cache for a Repository. This cache has multiple functions:
 //

cache/repo_cache_common.go 🔗

@@ -20,6 +20,16 @@ func (c *RepoCache) LocalConfig() repository.Config {
 	return c.repo.LocalConfig()
 }
 
+// GlobalConfig give access to the global scoped configuration
+func (c *RepoCache) GlobalConfig() repository.Config {
+	return c.repo.GlobalConfig()
+}
+
+// AnyConfig give access to a merged local/global configuration
+func (c *RepoCache) AnyConfig() repository.ConfigRead {
+	return c.repo.AnyConfig()
+}
+
 func (c *RepoCache) Keyring() repository.Keyring {
 	return c.repo.Keyring()
 }

commands/webui.go 🔗

@@ -139,7 +139,7 @@ func runWebUI(env *Env, opts webUIOptions, args []string) error {
 	env.out.Printf("Graphql Playground: http://%s/playground\n", addr)
 	env.out.Println("Press Ctrl+c to quit")
 
-	configOpen, err := env.repo.LocalConfig().ReadBool(webUIOpenConfigKey)
+	configOpen, err := env.repo.AnyConfig().ReadBool(webUIOpenConfigKey)
 	if err == repository.ErrNoConfigEntry {
 		// default to true
 		configOpen = true

repository/config.go 🔗

@@ -59,3 +59,67 @@ func ParseTimestamp(s string) (time.Time, error) {
 
 	return time.Unix(int64(timestamp), 0), nil
 }
+
+// mergeConfig is a helper to easily support RepoConfig.AnyConfig()
+// from two separate local and global Config
+func mergeConfig(local ConfigRead, global ConfigRead) *mergedConfig {
+	return &mergedConfig{
+		local:  local,
+		global: global,
+	}
+}
+
+var _ ConfigRead = &mergedConfig{}
+
+type mergedConfig struct {
+	local  ConfigRead
+	global ConfigRead
+}
+
+func (m *mergedConfig) ReadAll(keyPrefix string) (map[string]string, error) {
+	values, err := m.global.ReadAll(keyPrefix)
+	if err != nil {
+		return nil, err
+	}
+	locals, err := m.local.ReadAll(keyPrefix)
+	if err != nil {
+		return nil, err
+	}
+	for k, val := range locals {
+		values[k] = val
+	}
+	return values, nil
+}
+
+func (m *mergedConfig) ReadBool(key string) (bool, error) {
+	v, err := m.local.ReadBool(key)
+	if err == nil {
+		return v, nil
+	}
+	if err != ErrNoConfigEntry && err != ErrMultipleConfigEntry {
+		return false, err
+	}
+	return m.global.ReadBool(key)
+}
+
+func (m *mergedConfig) ReadString(key string) (string, error) {
+	val, err := m.local.ReadString(key)
+	if err == nil {
+		return val, nil
+	}
+	if err != ErrNoConfigEntry && err != ErrMultipleConfigEntry {
+		return "", err
+	}
+	return m.global.ReadString(key)
+}
+
+func (m *mergedConfig) ReadTimestamp(key string) (time.Time, error) {
+	val, err := m.local.ReadTimestamp(key)
+	if err == nil {
+		return val, nil
+	}
+	if err != ErrNoConfigEntry && err != ErrMultipleConfigEntry {
+		return time.Time{}, err
+	}
+	return m.global.ReadTimestamp(key)
+}

repository/config_test.go 🔗

@@ -0,0 +1,54 @@
+package repository
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestMergedConfig(t *testing.T) {
+	local := NewMemConfig()
+	global := NewMemConfig()
+	merged := mergeConfig(local, global)
+
+	require.NoError(t, global.StoreBool("bool", true))
+	require.NoError(t, global.StoreString("string", "foo"))
+	require.NoError(t, global.StoreTimestamp("timestamp", time.Unix(1234, 0)))
+
+	val1, err := merged.ReadBool("bool")
+	require.NoError(t, err)
+	require.Equal(t, val1, true)
+
+	val2, err := merged.ReadString("string")
+	require.NoError(t, err)
+	require.Equal(t, val2, "foo")
+
+	val3, err := merged.ReadTimestamp("timestamp")
+	require.NoError(t, err)
+	require.Equal(t, val3, time.Unix(1234, 0))
+
+	require.NoError(t, local.StoreBool("bool", false))
+	require.NoError(t, local.StoreString("string", "bar"))
+	require.NoError(t, local.StoreTimestamp("timestamp", time.Unix(5678, 0)))
+
+	val1, err = merged.ReadBool("bool")
+	require.NoError(t, err)
+	require.Equal(t, val1, false)
+
+	val2, err = merged.ReadString("string")
+	require.NoError(t, err)
+	require.Equal(t, val2, "bar")
+
+	val3, err = merged.ReadTimestamp("timestamp")
+	require.NoError(t, err)
+	require.Equal(t, val3, time.Unix(5678, 0))
+
+	all, err := merged.ReadAll("")
+	require.NoError(t, err)
+	require.Equal(t, all, map[string]string{
+		"bool":      "false",
+		"string":    "bar",
+		"timestamp": "5678",
+	})
+}

repository/git.go 🔗

@@ -4,8 +4,6 @@ package repository
 import (
 	"bytes"
 	"fmt"
-	"io"
-	"os/exec"
 	"path"
 	"strings"
 	"sync"
@@ -22,6 +20,7 @@ var _ TestedRepo = &GitRepo{}
 
 // GitRepo represents an instance of a (local) git repository.
 type GitRepo struct {
+	gitCli
 	path string
 
 	clocksMutex sync.Mutex
@@ -30,62 +29,6 @@ type GitRepo struct {
 	keyring Keyring
 }
 
-// LocalConfig give access to the repository scoped configuration
-func (repo *GitRepo) LocalConfig() Config {
-	return newGitConfig(repo, false)
-}
-
-// GlobalConfig give access to the git global configuration
-func (repo *GitRepo) GlobalConfig() Config {
-	return newGitConfig(repo, true)
-}
-
-func (repo *GitRepo) Keyring() Keyring {
-	return repo.keyring
-}
-
-// Run the given git command with the given I/O reader/writers, returning an error if it fails.
-func (repo *GitRepo) 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(repo.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 (repo *GitRepo) runGitCommandRaw(stdin io.Reader, args ...string) (string, string, error) {
-	var stdout bytes.Buffer
-	var stderr bytes.Buffer
-	err := repo.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 (repo *GitRepo) runGitCommandWithStdin(stdin io.Reader, args ...string) (string, error) {
-	stdout, stderr, err := repo.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 (repo *GitRepo) runGitCommand(args ...string) (string, error) {
-	return repo.runGitCommandWithStdin(nil, args...)
-}
-
 // NewGitRepo determines if the given working directory is inside of a git repository,
 // and returns the corresponding GitRepo instance if it is.
 func NewGitRepo(path string, clockLoaders []ClockLoader) (*GitRepo, error) {
@@ -95,6 +38,7 @@ func NewGitRepo(path string, clockLoaders []ClockLoader) (*GitRepo, error) {
 	}
 
 	repo := &GitRepo{
+		gitCli:  gitCli{path: path},
 		path:    path,
 		clocks:  make(map[string]lamport.Clock),
 		keyring: k,
@@ -112,6 +56,7 @@ func NewGitRepo(path string, clockLoaders []ClockLoader) (*GitRepo, error) {
 
 	// Fix the path to be sure we are at the root
 	repo.path = stdout
+	repo.gitCli.path = stdout
 
 	for _, loader := range clockLoaders {
 		allExist := true
@@ -135,6 +80,7 @@ func NewGitRepo(path string, clockLoaders []ClockLoader) (*GitRepo, error) {
 // 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),
 	}
@@ -150,6 +96,7 @@ func InitGitRepo(path string) (*GitRepo, error) {
 // 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),
 	}
@@ -162,6 +109,26 @@ func InitBareGitRepo(path string) (*GitRepo, error) {
 	return repo, nil
 }
 
+// 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

repository/git_cli.go 🔗

@@ -0,0 +1,56 @@
+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...)
+}

repository/git_config.go 🔗

@@ -14,24 +14,24 @@ import (
 var _ Config = &gitConfig{}
 
 type gitConfig struct {
-	repo         *GitRepo
+	cli          gitCli
 	localityFlag string
 }
 
-func newGitConfig(repo *GitRepo, global bool) *gitConfig {
+func newGitConfig(cli gitCli, global bool) *gitConfig {
 	localityFlag := "--local"
 	if global {
 		localityFlag = "--global"
 	}
 	return &gitConfig{
-		repo:         repo,
+		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.repo.runGitCommand("config", gc.localityFlag, "--replace-all", key, value)
+	_, err := gc.cli.runGitCommand("config", gc.localityFlag, "--replace-all", key, value)
 	return err
 }
 
@@ -45,7 +45,7 @@ func (gc *gitConfig) StoreTimestamp(key string, value time.Time) error {
 
 // ReadAll read all key/value pair matching the key prefix
 func (gc *gitConfig) ReadAll(keyPrefix string) (map[string]string, error) {
-	stdout, err := gc.repo.runGitCommand("config", gc.localityFlag, "--includes", "--get-regexp", keyPrefix)
+	stdout, err := gc.cli.runGitCommand("config", gc.localityFlag, "--includes", "--get-regexp", keyPrefix)
 
 	//   / \
 	//  / ! \
@@ -74,7 +74,7 @@ func (gc *gitConfig) ReadAll(keyPrefix string) (map[string]string, error) {
 }
 
 func (gc *gitConfig) ReadString(key string) (string, error) {
-	stdout, err := gc.repo.runGitCommand("config", gc.localityFlag, "--includes", "--get-all", key)
+	stdout, err := gc.cli.runGitCommand("config", gc.localityFlag, "--includes", "--get-all", key)
 
 	//   / \
 	//  / ! \
@@ -116,12 +116,12 @@ func (gc *gitConfig) ReadTimestamp(key string) (time.Time, error) {
 }
 
 func (gc *gitConfig) rmSection(keyPrefix string) error {
-	_, err := gc.repo.runGitCommand("config", gc.localityFlag, "--remove-section", keyPrefix)
+	_, err := gc.cli.runGitCommand("config", gc.localityFlag, "--remove-section", keyPrefix)
 	return err
 }
 
 func (gc *gitConfig) unsetAll(keyPrefix string) error {
-	_, err := gc.repo.runGitCommand("config", gc.localityFlag, "--unset-all", keyPrefix)
+	_, err := gc.cli.runGitCommand("config", gc.localityFlag, "--unset-all", keyPrefix)
 	return err
 }
 
@@ -180,7 +180,7 @@ func (gc *gitConfig) RemoveAll(keyPrefix string) error {
 }
 
 func (gc *gitConfig) gitVersion() (*semver.Version, error) {
-	versionOut, err := gc.repo.runGitCommand("version")
+	versionOut, err := gc.cli.runGitCommand("version")
 	if err != nil {
 		return nil, err
 	}

repository/gogit.go 🔗

@@ -158,14 +158,25 @@ func InitBareGoGitRepo(path string) (*GoGitRepo, error) {
 	}, nil
 }
 
+// LocalConfig give access to the repository scoped configuration
 func (repo *GoGitRepo) LocalConfig() Config {
 	return newGoGitConfig(repo.r)
 }
 
+// GlobalConfig give access to the global scoped configuration
 func (repo *GoGitRepo) GlobalConfig() Config {
-	panic("go-git doesn't support writing global config")
+	// TODO: replace that with go-git native implementation once it's supported
+	// see: https://github.com/go-git/go-git
+	// see: https://github.com/src-d/go-git/issues/760
+	return newGitConfig(gitCli{repo.path}, true)
 }
 
+// AnyConfig give access to a merged local/global configuration
+func (repo *GoGitRepo) AnyConfig() ConfigRead {
+	return mergeConfig(repo.LocalConfig(), repo.GlobalConfig())
+}
+
+// Keyring give access to a user-wide storage for secrets
 func (repo *GoGitRepo) Keyring() Keyring {
 	return repo.keyring
 }
@@ -288,6 +299,7 @@ func (repo *GoGitRepo) ReadData(hash Hash) ([]byte, error) {
 	return ioutil.ReadAll(r)
 }
 
+// StoreTree will store a mapping key-->Hash as a Git tree
 func (repo *GoGitRepo) StoreTree(mapping []TreeEntry) (Hash, error) {
 	var tree object.Tree
 
@@ -319,6 +331,7 @@ func (repo *GoGitRepo) StoreTree(mapping []TreeEntry) (Hash, error) {
 	return Hash(hash.String()), nil
 }
 
+// ReadTree will return the list of entries in a Git tree
 func (repo *GoGitRepo) ReadTree(hash Hash) ([]TreeEntry, error) {
 	obj, err := repo.r.TreeObject(plumbing.NewHash(hash.String()))
 	if err != nil {
@@ -342,10 +355,12 @@ func (repo *GoGitRepo) ReadTree(hash Hash) ([]TreeEntry, error) {
 	return treeEntries, nil
 }
 
+// StoreCommit will store a Git commit with the given Git tree
 func (repo *GoGitRepo) StoreCommit(treeHash Hash) (Hash, error) {
 	return repo.StoreCommitWithParent(treeHash, "")
 }
 
+// StoreCommit will store a Git commit with the given Git tree
 func (repo *GoGitRepo) StoreCommitWithParent(treeHash Hash, parent Hash) (Hash, error) {
 	cfg, err := repo.r.Config()
 	if err != nil {
@@ -386,6 +401,7 @@ func (repo *GoGitRepo) StoreCommitWithParent(treeHash Hash, parent Hash) (Hash,
 	return Hash(hash.String()), nil
 }
 
+// GetTreeHash return the git tree hash referenced in a commit
 func (repo *GoGitRepo) GetTreeHash(commit Hash) (Hash, error) {
 	obj, err := repo.r.CommitObject(plumbing.NewHash(commit.String()))
 	if err != nil {
@@ -395,6 +411,7 @@ func (repo *GoGitRepo) GetTreeHash(commit Hash) (Hash, error) {
 	return Hash(obj.TreeHash.String()), nil
 }
 
+// FindCommonAncestor will return the last common ancestor of two chain of commit
 func (repo *GoGitRepo) FindCommonAncestor(commit1 Hash, commit2 Hash) (Hash, error) {
 	obj1, err := repo.r.CommitObject(plumbing.NewHash(commit1.String()))
 	if err != nil {
@@ -413,14 +430,17 @@ func (repo *GoGitRepo) FindCommonAncestor(commit1 Hash, commit2 Hash) (Hash, err
 	return Hash(commits[0].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())))
 }
 
+// RemoveRef will remove a Git reference
 func (repo *GoGitRepo) RemoveRef(ref string) error {
 	return repo.r.Storer.RemoveReference(plumbing.ReferenceName(ref))
 }
 
+// ListRefs will return a list of Git ref matching the given refspec
 func (repo *GoGitRepo) ListRefs(refPrefix string) ([]string, error) {
 	refIter, err := repo.r.References()
 	if err != nil {
@@ -442,6 +462,7 @@ func (repo *GoGitRepo) ListRefs(refPrefix string) ([]string, error) {
 	return refs, nil
 }
 
+// RefExist will check if a reference exist in Git
 func (repo *GoGitRepo) RefExist(ref string) (bool, error) {
 	_, err := repo.r.Reference(plumbing.ReferenceName(ref), false)
 	if err == nil {
@@ -452,6 +473,7 @@ func (repo *GoGitRepo) RefExist(ref string) (bool, error) {
 	return false, err
 }
 
+// CopyRef will create a new reference with the same value as another one
 func (repo *GoGitRepo) CopyRef(source string, dest string) error {
 	r, err := repo.r.Reference(plumbing.ReferenceName(source), false)
 	if err != nil {
@@ -460,6 +482,7 @@ func (repo *GoGitRepo) CopyRef(source string, dest string) error {
 	return repo.r.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(dest), r.Hash()))
 }
 
+// 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 {

repository/mock_repo.go 🔗

@@ -35,20 +35,20 @@ func NewMockRepoForTest() *mockRepoForTest {
 var _ RepoConfig = &mockRepoConfig{}
 
 type mockRepoConfig struct {
-	config       *MemConfig
+	localConfig  *MemConfig
 	globalConfig *MemConfig
 }
 
 func NewMockRepoConfig() *mockRepoConfig {
 	return &mockRepoConfig{
-		config:       NewMemConfig(),
+		localConfig:  NewMemConfig(),
 		globalConfig: NewMemConfig(),
 	}
 }
 
 // LocalConfig give access to the repository scoped configuration
 func (r *mockRepoConfig) LocalConfig() Config {
-	return r.config
+	return r.localConfig
 }
 
 // GlobalConfig give access to the git global configuration
@@ -56,6 +56,11 @@ func (r *mockRepoConfig) GlobalConfig() Config {
 	return r.globalConfig
 }
 
+// AnyConfig give access to a merged local/global configuration
+func (r *mockRepoConfig) AnyConfig() ConfigRead {
+	return mergeConfig(r.localConfig, r.globalConfig)
+}
+
 var _ RepoKeyring = &mockRepoKeyring{}
 
 type mockRepoKeyring struct {

repository/repo.go 🔗

@@ -32,6 +32,12 @@ type ClockedRepo interface {
 type RepoConfig interface {
 	// LocalConfig give access to the repository scoped configuration
 	LocalConfig() Config
+
+	// GlobalConfig give access to the global scoped configuration
+	GlobalConfig() Config
+
+	// AnyConfig give access to a merged local/global configuration
+	AnyConfig() ConfigRead
 }
 
 // RepoKeyring give access to a user-wide storage for secrets

repository/repo_testing.go 🔗

@@ -53,160 +53,169 @@ func RepoTest(t *testing.T, creator RepoCreator, cleaner RepoCleaner) {
 		true:  "Bare",
 	} {
 		t.Run(name, func(t *testing.T) {
-			t.Run("Blob-Tree-Commit-Ref", func(t *testing.T) {
-				repo := creator(bare)
-				defer cleaner(repo)
-
-				// Blob
-
-				data := randomData()
-
-				blobHash1, err := repo.StoreData(data)
-				require.NoError(t, err)
-				require.True(t, blobHash1.IsValid())
-
-				blob1Read, err := repo.ReadData(blobHash1)
-				require.NoError(t, err)
-				require.Equal(t, data, blob1Read)
-
-				// Tree
-
-				blobHash2, err := repo.StoreData(randomData())
-				require.NoError(t, err)
-				blobHash3, err := repo.StoreData(randomData())
-				require.NoError(t, err)
-
-				tree1 := []TreeEntry{
-					{
-						ObjectType: Blob,
-						Hash:       blobHash1,
-						Name:       "blob1",
-					},
-					{
-						ObjectType: Blob,
-						Hash:       blobHash2,
-						Name:       "blob2",
-					},
-				}
-
-				treeHash1, err := repo.StoreTree(tree1)
-				require.NoError(t, err)
-				require.True(t, treeHash1.IsValid())
-
-				tree1Read, err := repo.ReadTree(treeHash1)
-				require.NoError(t, err)
-				require.ElementsMatch(t, tree1, tree1Read)
-
-				tree2 := []TreeEntry{
-					{
-						ObjectType: Tree,
-						Hash:       treeHash1,
-						Name:       "tree1",
-					},
-					{
-						ObjectType: Blob,
-						Hash:       blobHash3,
-						Name:       "blob3",
-					},
-				}
-
-				treeHash2, err := repo.StoreTree(tree2)
-				require.NoError(t, err)
-				require.True(t, treeHash2.IsValid())
-
-				tree2Read, err := repo.ReadTree(treeHash2)
-				require.NoError(t, err)
-				require.ElementsMatch(t, tree2, tree2Read)
-
-				// Commit
-
-				commit1, err := repo.StoreCommit(treeHash1)
-				require.NoError(t, err)
-				require.True(t, commit1.IsValid())
-
-				treeHash1Read, err := repo.GetTreeHash(commit1)
-				require.NoError(t, err)
-				require.Equal(t, treeHash1, treeHash1Read)
-
-				commit2, err := repo.StoreCommitWithParent(treeHash2, commit1)
-				require.NoError(t, err)
-				require.True(t, commit2.IsValid())
-
-				treeHash2Read, err := repo.GetTreeHash(commit2)
-				require.NoError(t, err)
-				require.Equal(t, treeHash2, treeHash2Read)
-
-				// Ref
-
-				exist1, err := repo.RefExist("refs/bugs/ref1")
-				require.NoError(t, err)
-				require.False(t, exist1)
-
-				err = repo.UpdateRef("refs/bugs/ref1", commit2)
-				require.NoError(t, err)
-
-				exist1, err = repo.RefExist("refs/bugs/ref1")
-				require.NoError(t, err)
-				require.True(t, exist1)
-
-				ls, err := repo.ListRefs("refs/bugs")
-				require.NoError(t, err)
-				require.ElementsMatch(t, []string{"refs/bugs/ref1"}, ls)
-
-				err = repo.CopyRef("refs/bugs/ref1", "refs/bugs/ref2")
-				require.NoError(t, err)
-
-				ls, err = repo.ListRefs("refs/bugs")
-				require.NoError(t, err)
-				require.ElementsMatch(t, []string{"refs/bugs/ref1", "refs/bugs/ref2"}, ls)
-
-				commits, err := repo.ListCommits("refs/bugs/ref2")
-				require.NoError(t, err)
-				require.ElementsMatch(t, []Hash{commit1, commit2}, commits)
-
-				// Graph
-
-				commit3, err := repo.StoreCommitWithParent(treeHash1, commit1)
-				require.NoError(t, err)
-
-				ancestorHash, err := repo.FindCommonAncestor(commit2, commit3)
-				require.NoError(t, err)
-				require.Equal(t, commit1, ancestorHash)
-
-				err = repo.RemoveRef("refs/bugs/ref1")
-				require.NoError(t, err)
-			})
+			repo := creator(bare)
+			defer cleaner(repo)
 
-			t.Run("Local config", func(t *testing.T) {
-				repo := creator(bare)
-				defer cleaner(repo)
+			t.Run("Data", func(t *testing.T) {
+				RepoDataTest(t, repo)
+			})
 
-				testConfig(t, repo.LocalConfig())
+			t.Run("Config", func(t *testing.T) {
+				RepoConfigTest(t, repo)
 			})
 
 			t.Run("Clocks", func(t *testing.T) {
-				repo := creator(bare)
-				defer cleaner(repo)
-
-				clock, err := repo.GetOrCreateClock("foo")
-				require.NoError(t, err)
-				require.Equal(t, lamport.Time(1), clock.Time())
+				RepoClockTest(t, repo)
+			})
+		})
+	}
+}
 
-				time, err := clock.Increment()
-				require.NoError(t, err)
-				require.Equal(t, lamport.Time(1), time)
-				require.Equal(t, lamport.Time(2), clock.Time())
+// helper to test a RepoConfig
+func RepoConfigTest(t *testing.T, repo RepoConfig) {
+	testConfig(t, repo.LocalConfig())
+}
 
-				clock2, err := repo.GetOrCreateClock("foo")
-				require.NoError(t, err)
-				require.Equal(t, lamport.Time(2), clock2.Time())
+// helper to test a RepoData
+func RepoDataTest(t *testing.T, repo RepoData) {
+	// Blob
+
+	data := randomData()
+
+	blobHash1, err := repo.StoreData(data)
+	require.NoError(t, err)
+	require.True(t, blobHash1.IsValid())
+
+	blob1Read, err := repo.ReadData(blobHash1)
+	require.NoError(t, err)
+	require.Equal(t, data, blob1Read)
+
+	// Tree
+
+	blobHash2, err := repo.StoreData(randomData())
+	require.NoError(t, err)
+	blobHash3, err := repo.StoreData(randomData())
+	require.NoError(t, err)
+
+	tree1 := []TreeEntry{
+		{
+			ObjectType: Blob,
+			Hash:       blobHash1,
+			Name:       "blob1",
+		},
+		{
+			ObjectType: Blob,
+			Hash:       blobHash2,
+			Name:       "blob2",
+		},
+	}
 
-				clock3, err := repo.GetOrCreateClock("bar")
-				require.NoError(t, err)
-				require.Equal(t, lamport.Time(1), clock3.Time())
-			})
-		})
+	treeHash1, err := repo.StoreTree(tree1)
+	require.NoError(t, err)
+	require.True(t, treeHash1.IsValid())
+
+	tree1Read, err := repo.ReadTree(treeHash1)
+	require.NoError(t, err)
+	require.ElementsMatch(t, tree1, tree1Read)
+
+	tree2 := []TreeEntry{
+		{
+			ObjectType: Tree,
+			Hash:       treeHash1,
+			Name:       "tree1",
+		},
+		{
+			ObjectType: Blob,
+			Hash:       blobHash3,
+			Name:       "blob3",
+		},
 	}
+
+	treeHash2, err := repo.StoreTree(tree2)
+	require.NoError(t, err)
+	require.True(t, treeHash2.IsValid())
+
+	tree2Read, err := repo.ReadTree(treeHash2)
+	require.NoError(t, err)
+	require.ElementsMatch(t, tree2, tree2Read)
+
+	// Commit
+
+	commit1, err := repo.StoreCommit(treeHash1)
+	require.NoError(t, err)
+	require.True(t, commit1.IsValid())
+
+	treeHash1Read, err := repo.GetTreeHash(commit1)
+	require.NoError(t, err)
+	require.Equal(t, treeHash1, treeHash1Read)
+
+	commit2, err := repo.StoreCommitWithParent(treeHash2, commit1)
+	require.NoError(t, err)
+	require.True(t, commit2.IsValid())
+
+	treeHash2Read, err := repo.GetTreeHash(commit2)
+	require.NoError(t, err)
+	require.Equal(t, treeHash2, treeHash2Read)
+
+	// Ref
+
+	exist1, err := repo.RefExist("refs/bugs/ref1")
+	require.NoError(t, err)
+	require.False(t, exist1)
+
+	err = repo.UpdateRef("refs/bugs/ref1", commit2)
+	require.NoError(t, err)
+
+	exist1, err = repo.RefExist("refs/bugs/ref1")
+	require.NoError(t, err)
+	require.True(t, exist1)
+
+	ls, err := repo.ListRefs("refs/bugs")
+	require.NoError(t, err)
+	require.ElementsMatch(t, []string{"refs/bugs/ref1"}, ls)
+
+	err = repo.CopyRef("refs/bugs/ref1", "refs/bugs/ref2")
+	require.NoError(t, err)
+
+	ls, err = repo.ListRefs("refs/bugs")
+	require.NoError(t, err)
+	require.ElementsMatch(t, []string{"refs/bugs/ref1", "refs/bugs/ref2"}, ls)
+
+	commits, err := repo.ListCommits("refs/bugs/ref2")
+	require.NoError(t, err)
+	require.ElementsMatch(t, []Hash{commit1, commit2}, commits)
+
+	// Graph
+
+	commit3, err := repo.StoreCommitWithParent(treeHash1, commit1)
+	require.NoError(t, err)
+
+	ancestorHash, err := repo.FindCommonAncestor(commit2, commit3)
+	require.NoError(t, err)
+	require.Equal(t, commit1, ancestorHash)
+
+	err = repo.RemoveRef("refs/bugs/ref1")
+	require.NoError(t, err)
+}
+
+// helper to test a RepoClock
+func RepoClockTest(t *testing.T, repo RepoClock) {
+	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), clock.Time())
+
+	clock2, err := repo.GetOrCreateClock("foo")
+	require.NoError(t, err)
+	require.Equal(t, lamport.Time(2), clock2.Time())
+
+	clock3, err := repo.GetOrCreateClock("bar")
+	require.NoError(t, err)
+	require.Equal(t, lamport.Time(1), clock3.Time())
 }
 
 func randomData() []byte {