diff options
Diffstat (limited to 'repository')
-rw-r--r-- | repository/config.go | 113 | ||||
-rw-r--r-- | repository/config_test.go | 54 | ||||
-rw-r--r-- | repository/config_testing.go | 97 | ||||
-rw-r--r-- | repository/git.go | 93 | ||||
-rw-r--r-- | repository/git_cli.go | 56 | ||||
-rw-r--r-- | repository/git_config.go | 18 | ||||
-rw-r--r-- | repository/git_testing.go | 18 | ||||
-rw-r--r-- | repository/gogit.go | 615 | ||||
-rw-r--r-- | repository/gogit_config.go | 235 | ||||
-rw-r--r-- | repository/gogit_test.go | 68 | ||||
-rw-r--r-- | repository/gogit_testing.go | 58 | ||||
-rw-r--r-- | repository/keyring.go | 50 | ||||
-rw-r--r-- | repository/mock_repo.go | 166 | ||||
-rw-r--r-- | repository/repo.go | 80 | ||||
-rw-r--r-- | repository/repo_testing.go | 262 | ||||
-rw-r--r-- | repository/tree_entry.go | 32 |
16 files changed, 1715 insertions, 300 deletions
diff --git a/repository/config.go b/repository/config.go index 4fa5c69b..4db8d4be 100644 --- a/repository/config.go +++ b/repository/config.go @@ -1,21 +1,23 @@ package repository import ( + "errors" "strconv" "time" ) +var ( + ErrNoConfigEntry = errors.New("no config entry for the given key") + ErrMultipleConfigEntry = errors.New("multiple config entry for the given key") +) + // Config represent the common function interacting with the repository config storage type Config interface { - // Store writes a single key/value pair in the config - StoreString(key, value string) error - - // Store writes a key and timestamp value to the config - StoreTimestamp(key string, value time.Time) error - - // Store writes a key and boolean value to the config - StoreBool(key string, value bool) error + ConfigRead + ConfigWrite +} +type ConfigRead interface { // ReadAll reads all key/value pair matching the key prefix ReadAll(keyPrefix string) (map[string]string, error) @@ -33,6 +35,17 @@ type Config interface { // Return ErrNoConfigEntry or ErrMultipleConfigEntry if // there is zero or more than one entry for this key ReadTimestamp(key string) (time.Time, error) +} + +type ConfigWrite interface { + // Store writes a single key/value pair in the config + StoreString(key, value string) error + + // Store writes a key and timestamp value to the config + StoreTimestamp(key string, value time.Time) error + + // Store writes a key and boolean value to the config + StoreBool(key string, value bool) error // RemoveAll removes all key/value pair matching the key prefix RemoveAll(keyPrefix string) error @@ -46,3 +59,87 @@ 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) +} + +var _ ConfigWrite = &configPanicWriter{} + +type configPanicWriter struct{} + +func (c configPanicWriter) StoreString(key, value string) error { + panic("not implemented") +} + +func (c configPanicWriter) StoreTimestamp(key string, value time.Time) error { + panic("not implemented") +} + +func (c configPanicWriter) StoreBool(key string, value bool) error { + panic("not implemented") +} + +func (c configPanicWriter) RemoveAll(keyPrefix string) error { + panic("not implemented") +} diff --git a/repository/config_test.go b/repository/config_test.go new file mode 100644 index 00000000..2a763540 --- /dev/null +++ b/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", + }) +} diff --git a/repository/config_testing.go b/repository/config_testing.go index 25639d59..445f8721 100644 --- a/repository/config_testing.go +++ b/repository/config_testing.go @@ -2,62 +2,115 @@ package repository import ( "testing" + "time" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func testConfig(t *testing.T, config Config) { + // string err := config.StoreString("section.key", "value") - assert.NoError(t, err) + require.NoError(t, err) val, err := config.ReadString("section.key") - assert.NoError(t, err) - assert.Equal(t, "value", val) + require.NoError(t, err) + require.Equal(t, "value", val) - err = config.StoreString("section.true", "true") - assert.NoError(t, err) + // bool + err = config.StoreBool("section.true", true) + require.NoError(t, err) val2, err := config.ReadBool("section.true") - assert.NoError(t, err) - assert.Equal(t, true, val2) + require.NoError(t, err) + require.Equal(t, true, val2) + // timestamp + err = config.StoreTimestamp("section.time", time.Unix(1234, 0)) + require.NoError(t, err) + + val3, err := config.ReadTimestamp("section.time") + require.NoError(t, err) + require.Equal(t, time.Unix(1234, 0), val3) + + // ReadAll configs, err := config.ReadAll("section") - assert.NoError(t, err) - assert.Equal(t, map[string]string{ + require.NoError(t, err) + require.Equal(t, map[string]string{ "section.key": "value", "section.true": "true", + "section.time": "1234", }, configs) + // RemoveAll err = config.RemoveAll("section.true") - assert.NoError(t, err) + require.NoError(t, err) configs, err = config.ReadAll("section") - assert.NoError(t, err) - assert.Equal(t, map[string]string{ - "section.key": "value", + require.NoError(t, err) + require.Equal(t, map[string]string{ + "section.key": "value", + "section.time": "1234", }, configs) _, err = config.ReadBool("section.true") - assert.Equal(t, ErrNoConfigEntry, err) + require.Equal(t, ErrNoConfigEntry, err) err = config.RemoveAll("section.nonexistingkey") - assert.Error(t, err) + require.Error(t, err) err = config.RemoveAll("section.key") - assert.NoError(t, err) + require.NoError(t, err) _, err = config.ReadString("section.key") - assert.Equal(t, ErrNoConfigEntry, err) + require.Equal(t, ErrNoConfigEntry, err) err = config.RemoveAll("nonexistingsection") - assert.Error(t, err) + require.Error(t, err) + + err = config.RemoveAll("section.time") + require.NoError(t, err) err = config.RemoveAll("section") - assert.Error(t, err) + require.Error(t, err) _, err = config.ReadString("section.key") - assert.Error(t, err) + require.Error(t, err) err = config.RemoveAll("section.key") - assert.Error(t, err) + require.Error(t, err) + + // section + subsections + require.NoError(t, config.StoreString("section.opt1", "foo")) + require.NoError(t, config.StoreString("section.opt2", "foo2")) + require.NoError(t, config.StoreString("section.subsection.opt1", "foo3")) + require.NoError(t, config.StoreString("section.subsection.opt2", "foo4")) + require.NoError(t, config.StoreString("section.subsection.subsection.opt1", "foo5")) + require.NoError(t, config.StoreString("section.subsection.subsection.opt2", "foo6")) + + all, err := config.ReadAll("section") + require.NoError(t, err) + require.Equal(t, map[string]string{ + "section.opt1": "foo", + "section.opt2": "foo2", + "section.subsection.opt1": "foo3", + "section.subsection.opt2": "foo4", + "section.subsection.subsection.opt1": "foo5", + "section.subsection.subsection.opt2": "foo6", + }, all) + + all, err = config.ReadAll("section.subsection") + require.NoError(t, err) + require.Equal(t, map[string]string{ + "section.subsection.opt1": "foo3", + "section.subsection.opt2": "foo4", + "section.subsection.subsection.opt1": "foo5", + "section.subsection.subsection.opt2": "foo6", + }, all) + + all, err = config.ReadAll("section.subsection.subsection") + require.NoError(t, err) + require.Equal(t, map[string]string{ + "section.subsection.subsection.opt1": "foo5", + "section.subsection.subsection.opt2": "foo6", + }, all) } diff --git a/repository/git.go b/repository/git.go index 3d756324..dba2d29d 100644 --- a/repository/git.go +++ b/repository/git.go @@ -4,8 +4,6 @@ package repository import ( "bytes" "fmt" - "io" - "os/exec" "path" "strings" "sync" @@ -22,70 +20,28 @@ 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 -} - -// 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) -} - -// 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...) + keyring Keyring } // 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) { + k, err := defaultKeyring() + if err != nil { + return nil, err + } + repo := &GitRepo{ - path: path, - clocks: make(map[string]lamport.Clock), + gitCli: gitCli{path: path}, + path: path, + clocks: make(map[string]lamport.Clock), + keyring: k, } // Check the repo and retrieve the root path @@ -100,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 @@ -123,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), } @@ -138,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), } @@ -150,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 @@ -290,8 +269,8 @@ func (repo *GitRepo) RemoveRef(ref string) error { } // ListRefs will return a list of Git ref matching the given refspec -func (repo *GitRepo) ListRefs(refspec string) ([]string, error) { - stdout, err := repo.runGitCommand("for-each-ref", "--format=%(refname)", 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 diff --git a/repository/git_cli.go b/repository/git_cli.go new file mode 100644 index 00000000..085b1cda --- /dev/null +++ b/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...) +} diff --git a/repository/git_config.go b/repository/git_config.go index 987cf195..b46cc69b 100644 --- a/repository/git_config.go +++ b/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 } diff --git a/repository/git_testing.go b/repository/git_testing.go index 5ae4ccc9..7d40bf1f 100644 --- a/repository/git_testing.go +++ b/repository/git_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 CreateTestRepo(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 SetupReposAndRemote() (repoA, repoB, remote TestedRepo) { @@ -56,3 +62,13 @@ func SetupReposAndRemote() (repoA, repoB, remote TestedRepo) { 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 new file mode 100644 index 00000000..09f714ea --- /dev/null +++ b/repository/gogit.go @@ -0,0 +1,615 @@ +package repository + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + stdpath "path" + "path/filepath" + "strings" + "sync" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" + + "github.com/MichaelMure/git-bug/util/lamport" +) + +var _ ClockedRepo = &GoGitRepo{} + +type GoGitRepo struct { + r *gogit.Repository + path string + + clocksMutex sync.Mutex + clocks map[string]lamport.Clock + + keyring Keyring +} + +func NewGoGitRepo(path string, clockLoaders []ClockLoader) (*GoGitRepo, error) { + path, err := detectGitPath(path) + if err != nil { + return nil, err + } + + r, err := gogit.PlainOpen(path) + if err != nil { + return nil, err + } + + k, err := defaultKeyring() + if err != nil { + return nil, err + } + + repo := &GoGitRepo{ + r: r, + path: path, + clocks: make(map[string]lamport.Clock), + keyring: k, + } + + for _, loader := range clockLoaders { + allExist := true + for _, name := range loader.Clocks { + if _, err := repo.getClock(name); err != nil { + allExist = false + } + } + + if !allExist { + err = loader.Witnesser(repo) + if err != nil { + return nil, err + } + } + } + + return repo, nil +} + +func detectGitPath(path string) (string, error) { + // normalize the path + path, err := filepath.Abs(path) + if err != nil { + return "", err + } + + for { + fi, err := os.Stat(stdpath.Join(path, ".git")) + if err == nil { + if !fi.IsDir() { + return "", fmt.Errorf(".git exist but is not a directory") + } + return stdpath.Join(path, ".git"), nil + } + if !os.IsNotExist(err) { + // unknown error + return "", err + } + + // detect bare repo + ok, err := isGitDir(path) + if err != nil { + return "", err + } + if ok { + return path, nil + } + + if parent := filepath.Dir(path); parent == path { + return "", fmt.Errorf(".git not found") + } else { + path = parent + } + } +} + +func isGitDir(path string) (bool, error) { + markers := []string{"HEAD", "objects", "refs"} + + for _, marker := range markers { + _, err := os.Stat(stdpath.Join(path, marker)) + if err == nil { + continue + } + if !os.IsNotExist(err) { + // unknown error + return false, err + } else { + return false, nil + } + } + + return true, nil +} + +// InitGoGitRepo create a new empty git repo at the given path +func InitGoGitRepo(path string) (*GoGitRepo, error) { + r, err := gogit.PlainInit(path, false) + if err != nil { + return nil, err + } + + return &GoGitRepo{ + r: r, + path: path + "/.git", + clocks: make(map[string]lamport.Clock), + }, nil +} + +// InitBareGoGitRepo create a new --bare empty git repo at the given path +func InitBareGoGitRepo(path string) (*GoGitRepo, error) { + r, err := gogit.PlainInit(path, true) + if err != nil { + return nil, err + } + + return &GoGitRepo{ + r: r, + path: path, + clocks: make(map[string]lamport.Clock), + }, nil +} + +// LocalConfig give access to the repository scoped configuration +func (repo *GoGitRepo) LocalConfig() Config { + return newGoGitLocalConfig(repo.r) +} + +// GlobalConfig give access to the global scoped configuration +func (repo *GoGitRepo) GlobalConfig() 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 newGoGitGlobalConfig(repo.r) +} + +// 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 +} + +// GetPath returns the path to the repo. +func (repo *GoGitRepo) GetPath() string { + return repo.path +} + +// GetUserName returns the name the the user has used to configure git +func (repo *GoGitRepo) GetUserName() (string, error) { + cfg, err := repo.r.Config() + if err != nil { + return "", err + } + + return cfg.User.Name, nil +} + +// GetUserEmail returns the email address that the user has used to configure git. +func (repo *GoGitRepo) GetUserEmail() (string, error) { + cfg, err := repo.r.Config() + if err != nil { + return "", err + } + + return cfg.User.Email, nil +} + +// GetCoreEditor returns the name of the editor that the user has used to configure git. +func (repo *GoGitRepo) GetCoreEditor() (string, error) { + // See https://git-scm.com/docs/git-var + // The order of preference is the $GIT_EDITOR environment variable, then core.editor configuration, then $VISUAL, then $EDITOR, and then the default chosen at compile time, which is usually vi. + + if val, ok := os.LookupEnv("GIT_EDITOR"); ok { + return val, nil + } + + val, err := repo.AnyConfig().ReadString("core.editor") + if err == nil && val != "" { + return val, nil + } + if err != nil && err != ErrNoConfigEntry { + return "", err + } + + if val, ok := os.LookupEnv("VISUAL"); ok { + return val, nil + } + + if val, ok := os.LookupEnv("EDITOR"); ok { + return val, nil + } + + return "vi", nil +} + +// GetRemotes returns the configured remotes repositories. +func (repo *GoGitRepo) GetRemotes() (map[string]string, error) { + cfg, err := repo.r.Config() + if err != nil { + return nil, err + } + + result := make(map[string]string, len(cfg.Remotes)) + for name, remote := range cfg.Remotes { + if len(remote.URLs) > 0 { + result[name] = remote.URLs[0] + } + } + + return result, nil +} + +// FetchRefs fetch git refs from a remote +func (repo *GoGitRepo) FetchRefs(remote string, refSpec string) (string, error) { + buf := bytes.NewBuffer(nil) + + err := repo.r.Fetch(&gogit.FetchOptions{ + RemoteName: remote, + RefSpecs: []config.RefSpec{config.RefSpec(refSpec)}, + Progress: buf, + }) + if err != nil { + return "", err + } + + return buf.String(), nil +} + +// PushRefs push git refs to a remote +func (repo *GoGitRepo) PushRefs(remote string, refSpec string) (string, error) { + buf := bytes.NewBuffer(nil) + + err := repo.r.Push(&gogit.PushOptions{ + RemoteName: remote, + RefSpecs: []config.RefSpec{config.RefSpec(refSpec)}, + Progress: buf, + }) + if err != nil { + return "", err + } + + return buf.String(), nil +} + +// StoreData will store arbitrary data and return the corresponding hash +func (repo *GoGitRepo) StoreData(data []byte) (Hash, error) { + obj := repo.r.Storer.NewEncodedObject() + obj.SetType(plumbing.BlobObject) + + w, err := obj.Writer() + if err != nil { + return "", err + } + + _, err = w.Write(data) + if err != nil { + return "", err + } + + h, err := repo.r.Storer.SetEncodedObject(obj) + if err != nil { + return "", err + } + + return Hash(h.String()), nil +} + +// ReadData will attempt to read arbitrary data from the given hash +func (repo *GoGitRepo) ReadData(hash Hash) ([]byte, error) { + obj, err := repo.r.BlobObject(plumbing.NewHash(hash.String())) + if err != nil { + return nil, err + } + + r, err := obj.Reader() + if err != nil { + return nil, err + } + + return ioutil.ReadAll(r) +} + +// StoreTree will store a mapping key-->Hash as a Git tree +func (repo *GoGitRepo) StoreTree(mapping []TreeEntry) (Hash, error) { + var tree object.Tree + + for _, entry := range mapping { + mode := filemode.Regular + if entry.ObjectType == Tree { + mode = filemode.Dir + } + + tree.Entries = append(tree.Entries, object.TreeEntry{ + Name: entry.Name, + Mode: mode, + Hash: plumbing.NewHash(entry.Hash.String()), + }) + } + + obj := repo.r.Storer.NewEncodedObject() + obj.SetType(plumbing.TreeObject) + err := tree.Encode(obj) + if err != nil { + return "", err + } + + hash, err := repo.r.Storer.SetEncodedObject(obj) + if err != nil { + return "", err + } + + return Hash(hash.String()), nil +} + +// ReadTree will return the list of entries in a Git tree +func (repo *GoGitRepo) ReadTree(hash Hash) ([]TreeEntry, error) { + h := plumbing.NewHash(hash.String()) + + // the given hash could be a tree or a commit + obj, err := repo.r.Storer.EncodedObject(plumbing.AnyObject, h) + if err != nil { + return nil, err + } + + var tree *object.Tree + switch obj.Type() { + case plumbing.TreeObject: + tree, err = object.DecodeTree(repo.r.Storer, obj) + case plumbing.CommitObject: + var commit *object.Commit + commit, err = object.DecodeCommit(repo.r.Storer, obj) + if err != nil { + return nil, err + } + tree, err = commit.Tree() + default: + return nil, fmt.Errorf("given hash is not a tree") + } + if err != nil { + return nil, err + } + + treeEntries := make([]TreeEntry, len(tree.Entries)) + for i, entry := range tree.Entries { + objType := Blob + if entry.Mode == filemode.Dir { + objType = Tree + } + + treeEntries[i] = TreeEntry{ + ObjectType: objType, + Hash: Hash(entry.Hash.String()), + Name: entry.Name, + } + } + + 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 { + return "", err + } + + commit := object.Commit{ + Author: object.Signature{ + cfg.Author.Name, + cfg.Author.Email, + time.Now(), + }, + Committer: object.Signature{ + cfg.Committer.Name, + cfg.Committer.Email, + time.Now(), + }, + Message: "", + TreeHash: plumbing.NewHash(treeHash.String()), + } + + if parent != "" { + commit.ParentHashes = []plumbing.Hash{plumbing.NewHash(parent.String())} + } + + obj := repo.r.Storer.NewEncodedObject() + obj.SetType(plumbing.CommitObject) + err = commit.Encode(obj) + if err != nil { + return "", err + } + + hash, err := repo.r.Storer.SetEncodedObject(obj) + if err != nil { + return "", err + } + + 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 { + return "", err + } + + 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 { + return "", err + } + obj2, err := repo.r.CommitObject(plumbing.NewHash(commit2.String())) + if err != nil { + return "", err + } + + commits, err := obj1.MergeBase(obj2) + if err != nil { + return "", 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 { + return nil, err + } + + refs := make([]string, 0) + + err = refIter.ForEach(func(ref *plumbing.Reference) error { + if strings.HasPrefix(ref.Name().String(), refPrefix) { + refs = append(refs, ref.Name().String()) + } + return nil + }) + if err != nil { + return nil, err + } + + 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 { + return true, nil + } else if err == plumbing.ErrReferenceNotFound { + return false, nil + } + 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 { + return err + } + 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 { + return nil, err + } + + commit, err := repo.r.CommitObject(r.Hash()) + 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 hashes, nil +} + +// GetOrCreateClock return a Lamport clock stored in the Repo. +// If the clock doesn't exist, it's created. +func (repo *GoGitRepo) GetOrCreateClock(name string) (lamport.Clock, error) { + c, err := repo.getClock(name) + if err == nil { + return c, nil + } + if err != ErrClockNotExist { + return nil, err + } + + repo.clocksMutex.Lock() + defer repo.clocksMutex.Unlock() + + p := stdpath.Join(repo.path, clockPath, name+"-clock") + + c, err = lamport.NewPersistedClock(p) + if err != nil { + return nil, err + } + + repo.clocks[name] = c + return c, nil +} + +func (repo *GoGitRepo) getClock(name string) (lamport.Clock, error) { + repo.clocksMutex.Lock() + defer repo.clocksMutex.Unlock() + + if c, ok := repo.clocks[name]; ok { + return c, nil + } + + p := stdpath.Join(repo.path, clockPath, name+"-clock") + + c, err := lamport.LoadPersistedClock(p) + if err == nil { + repo.clocks[name] = c + return c, nil + } + if err == lamport.ErrClockNotExist { + return nil, ErrClockNotExist + } + return nil, err +} + +// AddRemote add a new remote to the repository +// Not in the interface because it's only used for testing +func (repo *GoGitRepo) AddRemote(name string, url string) error { + _, err := repo.r.CreateRemote(&config.RemoteConfig{ + Name: name, + URLs: []string{url}, + }) + + return err +} diff --git a/repository/gogit_config.go b/repository/gogit_config.go new file mode 100644 index 00000000..7812de76 --- /dev/null +++ b/repository/gogit_config.go @@ -0,0 +1,235 @@ +package repository + +import ( + "fmt" + "strconv" + "strings" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" +) + +var _ Config = &goGitConfig{} + +type goGitConfig struct { + ConfigRead + ConfigWrite +} + +func newGoGitLocalConfig(repo *gogit.Repository) *goGitConfig { + return &goGitConfig{ + ConfigRead: &goGitConfigReader{getConfig: repo.Config}, + ConfigWrite: &goGitConfigWriter{repo: repo}, + } +} + +func newGoGitGlobalConfig(repo *gogit.Repository) *goGitConfig { + return &goGitConfig{ + ConfigRead: &goGitConfigReader{getConfig: func() (*config.Config, error) { + return config.LoadConfig(config.GlobalScope) + }}, + ConfigWrite: &configPanicWriter{}, + } +} + +var _ ConfigRead = &goGitConfigReader{} + +type goGitConfigReader struct { + getConfig func() (*config.Config, error) +} + +func (cr *goGitConfigReader) ReadAll(keyPrefix string) (map[string]string, error) { + cfg, err := cr.getConfig() + if err != nil { + return nil, err + } + + split := strings.Split(keyPrefix, ".") + result := make(map[string]string) + + switch { + case keyPrefix == "": + for _, section := range cfg.Raw.Sections { + for _, option := range section.Options { + result[fmt.Sprintf("%s.%s", section.Name, option.Key)] = option.Value + } + for _, subsection := range section.Subsections { + for _, option := range subsection.Options { + result[fmt.Sprintf("%s.%s.%s", section.Name, subsection.Name, option.Key)] = option.Value + } + } + } + case len(split) == 1: + if !cfg.Raw.HasSection(split[0]) { + return nil, nil + } + section := cfg.Raw.Section(split[0]) + for _, option := range section.Options { + result[fmt.Sprintf("%s.%s", section.Name, option.Key)] = option.Value + } + for _, subsection := range section.Subsections { + for _, option := range subsection.Options { + result[fmt.Sprintf("%s.%s.%s", section.Name, subsection.Name, option.Key)] = option.Value + } + } + default: + if !cfg.Raw.HasSection(split[0]) { + return nil, nil + } + section := cfg.Raw.Section(split[0]) + rest := strings.Join(split[1:], ".") + for _, subsection := range section.Subsections { + if strings.HasPrefix(subsection.Name, rest) { + for _, option := range subsection.Options { + result[fmt.Sprintf("%s.%s.%s", section.Name, subsection.Name, option.Key)] = option.Value + } + } + } + } + + return result, nil +} + +func (cr *goGitConfigReader) ReadBool(key string) (bool, error) { + val, err := cr.ReadString(key) + if err != nil { + return false, err + } + + return strconv.ParseBool(val) +} + +func (cr *goGitConfigReader) ReadString(key string) (string, error) { + cfg, err := cr.getConfig() + if err != nil { + return "", err + } + + split := strings.Split(key, ".") + + if len(split) <= 1 { + return "", fmt.Errorf("invalid key") + } + + sectionName := split[0] + if !cfg.Raw.HasSection(sectionName) { + return "", ErrNoConfigEntry + } + section := cfg.Raw.Section(sectionName) + + switch { + case len(split) == 2: + optionName := split[1] + if !section.HasOption(optionName) { + return "", ErrNoConfigEntry + } + if len(section.OptionAll(optionName)) > 1 { + return "", ErrMultipleConfigEntry + } + return section.Option(optionName), nil + default: + subsectionName := strings.Join(split[1:len(split)-2], ".") + optionName := split[len(split)-1] + if !section.HasSubsection(subsectionName) { + return "", ErrNoConfigEntry + } + subsection := section.Subsection(subsectionName) + if !subsection.HasOption(optionName) { + return "", ErrNoConfigEntry + } + if len(subsection.OptionAll(optionName)) > 1 { + return "", ErrMultipleConfigEntry + } + return subsection.Option(optionName), nil + } +} + +func (cr *goGitConfigReader) ReadTimestamp(key string) (time.Time, error) { + value, err := cr.ReadString(key) + if err != nil { + return time.Time{}, err + } + return ParseTimestamp(value) +} + +var _ ConfigWrite = &goGitConfigWriter{} + +// Only works for the local config as go-git only support that +type goGitConfigWriter struct { + repo *gogit.Repository +} + +func (cw *goGitConfigWriter) StoreString(key, value string) error { + cfg, err := cw.repo.Config() + if err != nil { + return err + } + + split := strings.Split(key, ".") + + switch { + case len(split) <= 1: + return fmt.Errorf("invalid key") + case len(split) == 2: + cfg.Raw.Section(split[0]).SetOption(split[1], value) + default: + section := split[0] + subsection := strings.Join(split[1:len(split)-1], ".") + option := split[len(split)-1] + cfg.Raw.Section(section).Subsection(subsection).SetOption(option, value) + } + + return cw.repo.SetConfig(cfg) +} + +func (cw *goGitConfigWriter) StoreTimestamp(key string, value time.Time) error { + return cw.StoreString(key, strconv.Itoa(int(value.Unix()))) +} + +func (cw *goGitConfigWriter) StoreBool(key string, value bool) error { + return cw.StoreString(key, strconv.FormatBool(value)) +} + +func (cw *goGitConfigWriter) RemoveAll(keyPrefix string) error { + cfg, err := cw.repo.Config() + if err != nil { + return err + } + + split := strings.Split(keyPrefix, ".") + + switch { + case keyPrefix == "": + cfg.Raw.Sections = nil + // warning: this does not actually remove everything as go-git config hold + // some entries in multiple places (cfg.User ...) + case len(split) == 1: + if cfg.Raw.HasSection(split[0]) { + cfg.Raw.RemoveSection(split[0]) + } else { + return fmt.Errorf("invalid key prefix") + } + default: + if !cfg.Raw.HasSection(split[0]) { + return fmt.Errorf("invalid key prefix") + } + section := cfg.Raw.Section(split[0]) + rest := strings.Join(split[1:], ".") + + ok := false + if section.HasSubsection(rest) { + section.RemoveSubsection(rest) + ok = true + } + if section.HasOption(rest) { + section.RemoveOption(rest) + ok = true + } + if !ok { + return fmt.Errorf("invalid key prefix") + } + } + + return cw.repo.SetConfig(cfg) +} diff --git a/repository/gogit_test.go b/repository/gogit_test.go new file mode 100644 index 00000000..fba990d3 --- /dev/null +++ b/repository/gogit_test.go @@ -0,0 +1,68 @@ +package repository + +import ( + "io/ioutil" + "os" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewGoGitRepo(t *testing.T) { + // Plain + plainRoot, err := ioutil.TempDir("", "") + require.NoError(t, err) + defer os.RemoveAll(plainRoot) + + _, err = InitGoGitRepo(plainRoot) + require.NoError(t, err) + plainGitDir := path.Join(plainRoot, ".git") + + // Bare + bareRoot, err := ioutil.TempDir("", "") + require.NoError(t, err) + defer os.RemoveAll(bareRoot) + + _, err = InitBareGoGitRepo(bareRoot) + require.NoError(t, err) + bareGitDir := bareRoot + + tests := []struct { + inPath string + outPath string + err bool + }{ + // errors + {"/", "", true}, + // parent dir of a repo + {filepath.Dir(plainRoot), "", true}, + + // Plain repo + {plainRoot, plainGitDir, false}, + {plainGitDir, plainGitDir, false}, + {path.Join(plainGitDir, "objects"), plainGitDir, false}, + + // Bare repo + {bareRoot, bareGitDir, false}, + {bareGitDir, bareGitDir, false}, + {path.Join(bareGitDir, "objects"), bareGitDir, false}, + } + + for i, tc := range tests { + r, err := NewGoGitRepo(tc.inPath, nil) + + if tc.err { + require.Error(t, err, i) + } else { + require.NoError(t, err, i) + assert.Equal(t, filepath.ToSlash(tc.outPath), filepath.ToSlash(r.GetPath()), i) + } + } +} + +func TestGoGitRepo(t *testing.T) { + RepoTest(t, CreateGoGitTestRepo, CleanupTestRepos) +} diff --git a/repository/gogit_testing.go b/repository/gogit_testing.go new file mode 100644 index 00000000..f20ff6be --- /dev/null +++ b/repository/gogit_testing.go @@ -0,0 +1,58 @@ +package repository + +import ( + "io/ioutil" + "log" +) + +// This is intended for testing only + +func CreateGoGitTestRepo(bare bool) TestedRepo { + dir, err := ioutil.TempDir("", "") + if err != nil { + log.Fatal(err) + } + + var creator func(string) (*GoGitRepo, error) + + if bare { + creator = InitBareGoGitRepo + } else { + creator = InitGoGitRepo + } + + repo, err := creator(dir) + if err != nil { + log.Fatal(err) + } + + config := repo.LocalConfig() + if err := config.StoreString("user.name", "testuser"); err != nil { + log.Fatal("failed to set user.name for test repository: ", err) + } + if err := config.StoreString("user.email", "testuser@example.com"); err != nil { + log.Fatal("failed to set user.email for test repository: ", err) + } + + return repo +} + +func SetupGoGitReposAndRemote() (repoA, repoB, remote TestedRepo) { + repoA = CreateGoGitTestRepo(false) + repoB = CreateGoGitTestRepo(false) + remote = CreateGoGitTestRepo(true) + + remoteAddr := "file://" + remote.GetPath() + + err := repoA.AddRemote("origin", remoteAddr) + if err != nil { + log.Fatal(err) + } + + err = repoB.AddRemote("origin", remoteAddr) + if err != nil { + log.Fatal(err) + } + + return repoA, repoB, remote +} diff --git a/repository/keyring.go b/repository/keyring.go new file mode 100644 index 00000000..f690b0b3 --- /dev/null +++ b/repository/keyring.go @@ -0,0 +1,50 @@ +package repository + +import ( + "os" + "path" + + "github.com/99designs/keyring" +) + +type Item = keyring.Item + +var ErrKeyringKeyNotFound = keyring.ErrKeyNotFound + +// Keyring provides the uniform interface over the underlying backends +type Keyring interface { + // Returns an Item matching the key or ErrKeyringKeyNotFound + Get(key string) (Item, error) + // Stores an Item on the keyring + Set(item Item) error + // Removes the item with matching key + Remove(key string) error + // Provides a slice of all keys stored on the keyring + Keys() ([]string, error) +} + +func defaultKeyring() (Keyring, error) { + ucd, err := os.UserConfigDir() + if err != nil { + return nil, err + } + + return keyring.Open(keyring.Config{ + // only use the file backend until https://github.com/99designs/keyring/issues/74 is resolved + AllowedBackends: []keyring.BackendType{ + keyring.FileBackend, + }, + + ServiceName: "git-bug", + + // Fallback encrypted file + FileDir: path.Join(ucd, "git-bug", "keyring"), + // As we write the file in the user's config directory, this file should already be protected by the OS against + // other user's access. We actually don't terribly need to protect it further and a password prompt across all + // UI's would be a pain. Therefore we use here a constant password so the file will be unreadable by generic file + // scanners if the user's machine get compromised. + FilePasswordFunc: func(string) (string, error) { + return "git-bug", nil + }, + }) +} diff --git a/repository/mock_repo.go b/repository/mock_repo.go index 576e984e..628939aa 100644 --- a/repository/mock_repo.go +++ b/repository/mock_repo.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "github.com/99designs/keyring" + "github.com/MichaelMure/git-bug/util/lamport" ) @@ -13,85 +15,143 @@ var _ TestedRepo = &mockRepoForTest{} // mockRepoForTest defines an instance of Repo that can be used for testing. type mockRepoForTest struct { - config *MemConfig - globalConfig *MemConfig - blobs map[Hash][]byte - trees map[Hash]string - commits map[Hash]commit - refs map[string]Hash - clocks map[string]lamport.Clock -} - -type commit struct { - treeHash Hash - parent Hash + *mockRepoConfig + *mockRepoKeyring + *mockRepoCommon + *mockRepoData + *mockRepoClock } func NewMockRepoForTest() *mockRepoForTest { return &mockRepoForTest{ - config: NewMemConfig(), + mockRepoConfig: NewMockRepoConfig(), + mockRepoKeyring: NewMockRepoKeyring(), + mockRepoCommon: NewMockRepoCommon(), + mockRepoData: NewMockRepoData(), + mockRepoClock: NewMockRepoClock(), + } +} + +var _ RepoConfig = &mockRepoConfig{} + +type mockRepoConfig struct { + localConfig *MemConfig + globalConfig *MemConfig +} + +func NewMockRepoConfig() *mockRepoConfig { + return &mockRepoConfig{ + localConfig: NewMemConfig(), globalConfig: NewMemConfig(), - blobs: make(map[Hash][]byte), - trees: make(map[Hash]string), - commits: make(map[Hash]commit), - refs: make(map[string]Hash), - clocks: make(map[string]lamport.Clock), } } // LocalConfig give access to the repository scoped configuration -func (r *mockRepoForTest) LocalConfig() Config { - return r.config +func (r *mockRepoConfig) LocalConfig() Config { + return r.localConfig } // GlobalConfig give access to the git global configuration -func (r *mockRepoForTest) GlobalConfig() Config { +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 { + keyring *keyring.ArrayKeyring +} + +func NewMockRepoKeyring() *mockRepoKeyring { + return &mockRepoKeyring{ + keyring: keyring.NewArrayKeyring(nil), + } +} + +// Keyring give access to a user-wide storage for secrets +func (r *mockRepoKeyring) Keyring() Keyring { + return r.keyring +} + +var _ RepoCommon = &mockRepoCommon{} + +type mockRepoCommon struct{} + +func NewMockRepoCommon() *mockRepoCommon { + return &mockRepoCommon{} +} + // GetPath returns the path to the repo. -func (r *mockRepoForTest) GetPath() string { +func (r *mockRepoCommon) GetPath() string { return "~/mockRepo/" } -func (r *mockRepoForTest) GetUserName() (string, error) { +func (r *mockRepoCommon) GetUserName() (string, error) { return "René Descartes", nil } // GetUserEmail returns the email address that the user has used to configure git. -func (r *mockRepoForTest) GetUserEmail() (string, error) { +func (r *mockRepoCommon) GetUserEmail() (string, error) { return "user@example.com", nil } // GetCoreEditor returns the name of the editor that the user has used to configure git. -func (r *mockRepoForTest) GetCoreEditor() (string, error) { +func (r *mockRepoCommon) GetCoreEditor() (string, error) { return "vi", nil } // GetRemotes returns the configured remotes repositories. -func (r *mockRepoForTest) GetRemotes() (map[string]string, error) { +func (r *mockRepoCommon) GetRemotes() (map[string]string, error) { return map[string]string{ "origin": "git://github.com/MichaelMure/git-bug", }, nil } +var _ RepoData = &mockRepoData{} + +type commit struct { + treeHash Hash + parent Hash +} + +type mockRepoData struct { + blobs map[Hash][]byte + trees map[Hash]string + commits map[Hash]commit + refs map[string]Hash +} + +func NewMockRepoData() *mockRepoData { + return &mockRepoData{ + blobs: make(map[Hash][]byte), + trees: make(map[Hash]string), + commits: make(map[Hash]commit), + refs: make(map[string]Hash), + } +} + // PushRefs push git refs to a remote -func (r *mockRepoForTest) PushRefs(remote string, refSpec string) (string, error) { +func (r *mockRepoData) PushRefs(remote string, refSpec string) (string, error) { return "", nil } -func (r *mockRepoForTest) FetchRefs(remote string, refSpec string) (string, error) { +func (r *mockRepoData) FetchRefs(remote string, refSpec string) (string, error) { return "", nil } -func (r *mockRepoForTest) StoreData(data []byte) (Hash, error) { +func (r *mockRepoData) StoreData(data []byte) (Hash, error) { rawHash := sha1.Sum(data) hash := Hash(fmt.Sprintf("%x", rawHash)) r.blobs[hash] = data return hash, nil } -func (r *mockRepoForTest) ReadData(hash Hash) ([]byte, error) { +func (r *mockRepoData) ReadData(hash Hash) ([]byte, error) { data, ok := r.blobs[hash] if !ok { @@ -101,7 +161,7 @@ func (r *mockRepoForTest) ReadData(hash Hash) ([]byte, error) { return data, nil } -func (r *mockRepoForTest) StoreTree(entries []TreeEntry) (Hash, error) { +func (r *mockRepoData) StoreTree(entries []TreeEntry) (Hash, error) { buffer := prepareTreeEntries(entries) rawHash := sha1.Sum(buffer.Bytes()) hash := Hash(fmt.Sprintf("%x", rawHash)) @@ -110,7 +170,7 @@ func (r *mockRepoForTest) StoreTree(entries []TreeEntry) (Hash, error) { return hash, nil } -func (r *mockRepoForTest) StoreCommit(treeHash Hash) (Hash, error) { +func (r *mockRepoData) StoreCommit(treeHash Hash) (Hash, error) { rawHash := sha1.Sum([]byte(treeHash)) hash := Hash(fmt.Sprintf("%x", rawHash)) r.commits[hash] = commit{ @@ -119,7 +179,7 @@ func (r *mockRepoForTest) StoreCommit(treeHash Hash) (Hash, error) { return hash, nil } -func (r *mockRepoForTest) StoreCommitWithParent(treeHash Hash, parent Hash) (Hash, error) { +func (r *mockRepoData) StoreCommitWithParent(treeHash Hash, parent Hash) (Hash, error) { rawHash := sha1.Sum([]byte(treeHash + parent)) hash := Hash(fmt.Sprintf("%x", rawHash)) r.commits[hash] = commit{ @@ -129,22 +189,22 @@ func (r *mockRepoForTest) StoreCommitWithParent(treeHash Hash, parent Hash) (Has return hash, nil } -func (r *mockRepoForTest) UpdateRef(ref string, hash Hash) error { +func (r *mockRepoData) UpdateRef(ref string, hash Hash) error { r.refs[ref] = hash return nil } -func (r *mockRepoForTest) RemoveRef(ref string) error { +func (r *mockRepoData) RemoveRef(ref string) error { delete(r.refs, ref) return nil } -func (r *mockRepoForTest) RefExist(ref string) (bool, error) { +func (r *mockRepoData) RefExist(ref string) (bool, error) { _, exist := r.refs[ref] return exist, nil } -func (r *mockRepoForTest) CopyRef(source string, dest string) error { +func (r *mockRepoData) CopyRef(source string, dest string) error { hash, exist := r.refs[source] if !exist { @@ -155,11 +215,11 @@ func (r *mockRepoForTest) CopyRef(source string, dest string) error { return nil } -func (r *mockRepoForTest) ListRefs(refspec string) ([]string, error) { +func (r *mockRepoData) ListRefs(refPrefix string) ([]string, error) { var keys []string for k := range r.refs { - if strings.HasPrefix(k, refspec) { + if strings.HasPrefix(k, refPrefix) { keys = append(keys, k) } } @@ -167,7 +227,7 @@ func (r *mockRepoForTest) ListRefs(refspec string) ([]string, error) { return keys, nil } -func (r *mockRepoForTest) ListCommits(ref string) ([]Hash, error) { +func (r *mockRepoData) ListCommits(ref string) ([]Hash, error) { var hashes []Hash hash := r.refs[ref] @@ -186,7 +246,7 @@ func (r *mockRepoForTest) ListCommits(ref string) ([]Hash, error) { return hashes, nil } -func (r *mockRepoForTest) ReadTree(hash Hash) ([]TreeEntry, error) { +func (r *mockRepoData) ReadTree(hash Hash) ([]TreeEntry, error) { var data string data, ok := r.trees[hash] @@ -209,7 +269,7 @@ func (r *mockRepoForTest) ReadTree(hash Hash) ([]TreeEntry, error) { return readTreeEntries(data) } -func (r *mockRepoForTest) FindCommonAncestor(hash1 Hash, hash2 Hash) (Hash, error) { +func (r *mockRepoData) FindCommonAncestor(hash1 Hash, hash2 Hash) (Hash, error) { ancestor1 := []Hash{hash1} for hash1 != "" { @@ -241,7 +301,7 @@ func (r *mockRepoForTest) FindCommonAncestor(hash1 Hash, hash2 Hash) (Hash, erro } } -func (r *mockRepoForTest) GetTreeHash(commit Hash) (Hash, error) { +func (r *mockRepoData) GetTreeHash(commit Hash) (Hash, error) { c, ok := r.commits[commit] if !ok { return "", fmt.Errorf("unknown commit") @@ -250,7 +310,21 @@ func (r *mockRepoForTest) GetTreeHash(commit Hash) (Hash, error) { return c.treeHash, nil } -func (r *mockRepoForTest) GetOrCreateClock(name string) (lamport.Clock, error) { +func (r *mockRepoData) AddRemote(name string, url string) error { + panic("implement me") +} + +type mockRepoClock struct { + clocks map[string]lamport.Clock +} + +func NewMockRepoClock() *mockRepoClock { + return &mockRepoClock{ + clocks: make(map[string]lamport.Clock), + } +} + +func (r *mockRepoClock) GetOrCreateClock(name string) (lamport.Clock, error) { if c, ok := r.clocks[name]; ok { return c, nil } @@ -259,7 +333,3 @@ func (r *mockRepoForTest) GetOrCreateClock(name string) (lamport.Clock, error) { r.clocks[name] = c return c, nil } - -func (r *mockRepoForTest) AddRemote(name string, url string) error { - panic("implement me") -} diff --git a/repository/repo.go b/repository/repo.go index 30d95806..4b45a1c5 100644 --- a/repository/repo.go +++ b/repository/repo.go @@ -2,29 +2,48 @@ package repository import ( - "bytes" "errors" - "strings" "github.com/MichaelMure/git-bug/util/lamport" ) var ( - ErrNoConfigEntry = errors.New("no config entry for the given key") - ErrMultipleConfigEntry = errors.New("multiple config entry for the given key") // ErrNotARepo is the error returned when the git repo root wan'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") ) +// Repo represents a source code repository. +type Repo interface { + RepoConfig + RepoKeyring + RepoCommon + RepoData +} + +// ClockedRepo is a Repo that also has Lamport clocks +type ClockedRepo interface { + Repo + RepoClock +} + // RepoConfig access the configuration of a repository type RepoConfig interface { // LocalConfig give access to the repository scoped configuration LocalConfig() Config - // GlobalConfig give access to the git global configuration + // 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 +type RepoKeyring interface { + // Keyring give access to a user-wide storage for secrets + Keyring() Keyring } // RepoCommon represent the common function the we want all the repo to implement @@ -45,11 +64,8 @@ type RepoCommon interface { GetRemotes() (map[string]string, error) } -// Repo represents a source code repository. -type Repo interface { - RepoConfig - RepoCommon - +// 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) @@ -66,6 +82,7 @@ type Repo interface { StoreTree(mapping []TreeEntry) (Hash, error) // ReadTree will return the list of entries in a Git tree + // The given hash could be from either a commit or a tree ReadTree(hash Hash) ([]TreeEntry, error) // StoreCommit will store a Git commit with the given Git tree @@ -87,7 +104,7 @@ type Repo interface { RemoveRef(ref string) error // ListRefs will return a list of Git ref matching the given refspec - ListRefs(refspec string) ([]string, error) + ListRefs(refPrefix string) ([]string, error) // RefExist will check if a reference exist in Git RefExist(ref string) (bool, error) @@ -99,10 +116,8 @@ type Repo interface { ListCommits(ref string) ([]Hash, error) } -// ClockedRepo is a Repo that also has Lamport clocks -type ClockedRepo interface { - Repo - +// RepoClock give access to Lamport clocks +type RepoClock interface { // GetOrCreateClock return a Lamport clock stored in the Repo. // If the clock doesn't exist, it's created. GetOrCreateClock(name string) (lamport.Clock, error) @@ -120,41 +135,14 @@ type ClockLoader struct { Witnesser func(repo ClockedRepo) error } -func prepareTreeEntries(entries []TreeEntry) bytes.Buffer { - var buffer bytes.Buffer - - for _, entry := range entries { - buffer.WriteString(entry.Format()) - } - - return buffer -} - -func readTreeEntries(s string) ([]TreeEntry, error) { - split := strings.Split(strings.TrimSpace(s), "\n") - - casted := make([]TreeEntry, len(split)) - for i, line := range split { - if line == "" { - continue - } - - entry, err := ParseTreeEntry(line) - - if err != nil { - return nil, err - } - - casted[i] = entry - } - - return casted, nil -} - // TestedRepo is an extended ClockedRepo with function for testing only type TestedRepo interface { ClockedRepo + repoTest +} +// repoTest give access to test only functions +type repoTest interface { // AddRemote add a new remote to the repository AddRemote(name string, url string) error } diff --git a/repository/repo_testing.go b/repository/repo_testing.go index 28eb9a21..41b3609e 100644 --- a/repository/repo_testing.go +++ b/repository/repo_testing.go @@ -7,8 +7,9 @@ import ( "strings" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/util/lamport" ) func CleanupTestRepos(repos ...Repo) { @@ -47,136 +48,179 @@ type RepoCleaner func(repos ...Repo) // Test suite for a Repo implementation func RepoTest(t *testing.T, creator RepoCreator, cleaner RepoCleaner) { - t.Run("Blob-Tree-Commit-Ref", func(t *testing.T) { - repo := creator(false) - defer cleaner(repo) - - // Blob - - data := randomData() - - blobHash1, err := repo.StoreData(data) - require.NoError(t, err) - assert.True(t, blobHash1.IsValid()) - - blob1Read, err := repo.ReadData(blobHash1) - require.NoError(t, err) - assert.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", - }, - } + for bare, name := range map[bool]string{ + false: "Plain", + true: "Bare", + } { + t.Run(name, func(t *testing.T) { + repo := creator(bare) + defer cleaner(repo) + + t.Run("Data", func(t *testing.T) { + RepoDataTest(t, repo) + }) + + t.Run("Config", func(t *testing.T) { + RepoConfigTest(t, repo) + }) + + t.Run("Clocks", func(t *testing.T) { + RepoClockTest(t, repo) + }) + }) + } +} - treeHash1, err := repo.StoreTree(tree1) - require.NoError(t, err) - assert.True(t, treeHash1.IsValid()) - - tree1Read, err := repo.ReadTree(treeHash1) - require.NoError(t, err) - assert.ElementsMatch(t, tree1, tree1Read) - - tree2 := []TreeEntry{ - { - ObjectType: Tree, - Hash: treeHash1, - Name: "tree1", - }, - { - ObjectType: Blob, - Hash: blobHash3, - Name: "blob3", - }, - } +// helper to test a RepoConfig +func RepoConfigTest(t *testing.T, repo RepoConfig) { + testConfig(t, repo.LocalConfig()) +} + +// 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", + }, + } + + 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()) - treeHash2, err := repo.StoreTree(tree2) - require.NoError(t, err) - assert.True(t, treeHash2.IsValid()) + tree2Read, err := repo.ReadTree(treeHash2) + require.NoError(t, err) + require.ElementsMatch(t, tree2, tree2Read) - tree2Read, err := repo.ReadTree(treeHash2) - require.NoError(t, err) - assert.ElementsMatch(t, tree2, tree2Read) + // Commit - // Commit + commit1, err := repo.StoreCommit(treeHash1) + require.NoError(t, err) + require.True(t, commit1.IsValid()) - commit1, err := repo.StoreCommit(treeHash1) - require.NoError(t, err) - assert.True(t, commit1.IsValid()) + treeHash1Read, err := repo.GetTreeHash(commit1) + require.NoError(t, err) + require.Equal(t, treeHash1, treeHash1Read) - treeHash1Read, err := repo.GetTreeHash(commit1) - require.NoError(t, err) - assert.Equal(t, treeHash1, treeHash1Read) + commit2, err := repo.StoreCommitWithParent(treeHash2, commit1) + require.NoError(t, err) + require.True(t, commit2.IsValid()) - commit2, err := repo.StoreCommitWithParent(treeHash2, commit1) - require.NoError(t, err) - assert.True(t, commit2.IsValid()) + treeHash2Read, err := repo.GetTreeHash(commit2) + require.NoError(t, err) + require.Equal(t, treeHash2, treeHash2Read) - treeHash2Read, err := repo.GetTreeHash(commit2) - require.NoError(t, err) - assert.Equal(t, treeHash2, treeHash2Read) + // ReadTree should accept tree and commit hashes + tree1read, err := repo.ReadTree(commit1) + require.NoError(t, err) + require.Equal(t, tree1read, tree1) - // Ref + // Ref - exist1, err := repo.RefExist("refs/bugs/ref1") - require.NoError(t, err) - assert.False(t, exist1) + 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) + err = repo.UpdateRef("refs/bugs/ref1", commit2) + require.NoError(t, err) - exist1, err = repo.RefExist("refs/bugs/ref1") - require.NoError(t, err) - assert.True(t, exist1) + 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) - assert.ElementsMatch(t, []string{"refs/bugs/ref1"}, ls) + 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) + err = repo.CopyRef("refs/bugs/ref1", "refs/bugs/ref2") + require.NoError(t, err) - ls, err = repo.ListRefs("refs/bugs") - require.NoError(t, err) - assert.ElementsMatch(t, []string{"refs/bugs/ref1", "refs/bugs/ref2"}, ls) + 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) - assert.ElementsMatch(t, []Hash{commit1, commit2}, commits) + commits, err := repo.ListCommits("refs/bugs/ref2") + require.NoError(t, err) + require.Equal(t, []Hash{commit1, commit2}, commits) - // Graph + // Graph - commit3, err := repo.StoreCommitWithParent(treeHash1, commit1) - require.NoError(t, err) + 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) +} - ancestorHash, err := repo.FindCommonAncestor(commit2, commit3) - require.NoError(t, err) - assert.Equal(t, commit1, ancestorHash) +// 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()) - err = repo.RemoveRef("refs/bugs/ref1") - require.NoError(t, err) - }) + time, err := clock.Increment() + require.NoError(t, err) + require.Equal(t, lamport.Time(1), time) + require.Equal(t, lamport.Time(2), clock.Time()) - t.Run("Local config", func(t *testing.T) { - repo := creator(false) - defer cleaner(repo) + clock2, err := repo.GetOrCreateClock("foo") + require.NoError(t, err) + require.Equal(t, lamport.Time(2), clock2.Time()) - testConfig(t, repo.LocalConfig()) - }) + clock3, err := repo.GetOrCreateClock("bar") + require.NoError(t, err) + require.Equal(t, lamport.Time(1), clock3.Time()) } func randomData() []byte { diff --git a/repository/tree_entry.go b/repository/tree_entry.go index 8b3de8e2..6c5ec1a5 100644 --- a/repository/tree_entry.go +++ b/repository/tree_entry.go @@ -1,6 +1,7 @@ package repository import ( + "bytes" "fmt" "strings" ) @@ -68,3 +69,34 @@ func ParseObjectType(mode, objType string) (ObjectType, error) { return Unknown, fmt.Errorf("Unknown git object type %s %s", mode, objType) } } + +func prepareTreeEntries(entries []TreeEntry) bytes.Buffer { + var buffer bytes.Buffer + + for _, entry := range entries { + buffer.WriteString(entry.Format()) + } + + return buffer +} + +func readTreeEntries(s string) ([]TreeEntry, error) { + split := strings.Split(strings.TrimSpace(s), "\n") + + casted := make([]TreeEntry, len(split)) + for i, line := range split { + if line == "" { + continue + } + + entry, err := ParseTreeEntry(line) + + if err != nil { + return nil, err + } + + casted[i] = entry + } + + return casted, nil +} |