diff --git a/README.md b/README.md index 4d3bfeb..9c2da79 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,10 @@ By default, the config file is searched for in `[HOME_DIR]./config/grm/config.ya | command | Description | |---------|-------------| | sync | Fetches changes from repositories or pulls a repository if one does not exist. +| status | Get repository information - what is the current branch, how many commits are above and behind it for each remote. ## Changelog -0.1 Add sync command - allow to fetch and clone repositories +- 0.2.0 Add status command - get information about the current status in the repository +- 0.1.1 Allow to use env vars in config +- 0.1.0 Add sync command - allow to fetch and clone repositories diff --git a/app/app.go b/app/app.go index d7877b3..86be04c 100644 --- a/app/app.go +++ b/app/app.go @@ -10,7 +10,7 @@ import ( const ( APP_NAME = "Git repository manager" APP_DESCRIPTION = "Manage your repository with simple app" - VERSION = "0.1.1" + VERSION = "0.2.0" ) type GitRepositoryManager struct { @@ -49,7 +49,6 @@ func (g *GitRepositoryManager) Parse(args []string) { func (g *GitRepositoryManager) Run() { if g.cliArguments.Sync { - g.console.InfoFMsg("Synchronizing repositories") println() sync := commands.NewSynchronizer(g.configuration.Workspace) @@ -58,6 +57,11 @@ func (g *GitRepositoryManager) Run() { g.console.InfoFMsg("All repositories are synced") } + if g.cliArguments.Status { + status := commands.NewStatusChecker(g.configuration.Workspace) + g.runCommand(status) + } + if g.cliArguments.Version { g.console.InfoFMsg("Current version: %v", VERSION) } diff --git a/app/app_test.go b/app/app_test.go index aae2b0e..cb30a11 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -90,5 +90,5 @@ func Example_test_sync_output() { // Output: // Info: Synchronizing repositories // Info: All repositories are synced - // Info: Current version: 0.1.1 + // Info: Current version: 0.2.0 } diff --git a/app/console_output.go b/app/console_output.go index 641ebda..1f8d0a1 100644 --- a/app/console_output.go +++ b/app/console_output.go @@ -39,7 +39,7 @@ func (co ConsoleOutput) InfoFMsg(format string, a ...interface{}) { func (co ConsoleOutput) UnchangedStatusF(format string, a ...interface{}) { msg := fmt.Sprintf(format, a...) if co.Color { - msg = fmt.Sprintf("%v%v", colorGreen, msg) + msg = fmt.Sprintf("%v%v%v", colorGreen, msg, colorReset) } fmt.Println(msg) } @@ -47,7 +47,7 @@ func (co ConsoleOutput) UnchangedStatusF(format string, a ...interface{}) { func (co ConsoleOutput) ChangedStatusF(format string, a ...interface{}) { msg := fmt.Sprintf(format, a...) if co.Color { - msg = fmt.Sprintf("%v%v", colorYellow, msg) + msg = fmt.Sprintf("%v%v%v", colorYellow, msg, colorReset) } fmt.Println(msg) } @@ -55,7 +55,7 @@ func (co ConsoleOutput) ChangedStatusF(format string, a ...interface{}) { func (co ConsoleOutput) ErrorStatusF(format string, a ...interface{}) { msg := fmt.Sprintf(format, a...) if co.Color { - msg = fmt.Sprintf("%v%v", colorRed, msg) + msg = fmt.Sprintf("%v%v%v", colorRed, msg, colorReset) } fmt.Println(msg) } diff --git a/commands/status_cmd.go b/commands/status_cmd.go new file mode 100644 index 0000000..bb782e3 --- /dev/null +++ b/commands/status_cmd.go @@ -0,0 +1,164 @@ +package commands + +import ( + "fmt" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "gitlab.com/revalus/grm/config" +) + +type StatusChecker struct { + workspace string +} + +func NewStatusChecker(workspace string) StatusChecker { + return StatusChecker{ + workspace: workspace, + } +} + +func findNumberOfCommitDiffs(srcCommit *object.Commit, dstCommit *object.Commit) int { + + getFiveElementsFromHashes := func(commit *object.Commit, hashedSlice *[]string) *object.Commit { + + var err error + for iter := 0; iter <= 5; iter++ { + + *hashedSlice = append(*hashedSlice, commit.Hash.String()) + commit, err = commit.Parents().Next() + + if err != nil { + return nil + } + } + return commit + } + + getRangeDiff := func(listFist, listSecond []string) (int, bool) { + diffRange := 0 + + for _, itemFirst := range listFist { + for _, itemSecond := range listSecond { + if itemFirst == itemSecond { + return diffRange, true + } + + } + diffRange++ + } + + return diffRange, false + } + + baseCommitHashes := []string{} + destCommitHashes := []string{} + for { + + if srcCommit != nil { + srcCommit = getFiveElementsFromHashes(srcCommit, &baseCommitHashes) + } + if dstCommit != nil { + dstCommit = getFiveElementsFromHashes(dstCommit, &destCommitHashes) + } + + diff, finished := getRangeDiff(baseCommitHashes, destCommitHashes) + + if finished { + return diff + } + } +} + +func (sc StatusChecker) Command(repoCfg config.RepositoryConfig, status chan CommandStatus) { + + cmdStatus := CommandStatus{ + Name: repoCfg.Name, + Changed: false, + Message: "", + Error: false, + } + + destPath := fmt.Sprintf("%v/%v", sc.workspace, repoCfg.Dest) + repo, err := git.PlainOpen(destPath) + + if err != nil { + cmdStatus.Error = true + cmdStatus.Message = err.Error() + status <- cmdStatus + return + } + + headReference, err := repo.Head() + if err != nil { + cmdStatus.Error = true + cmdStatus.Message = err.Error() + status <- cmdStatus + return + } + + remotes, err := repo.Remotes() + if err != nil || len(remotes) == 0 { + cmdStatus.Error = true + cmdStatus.Message = "cannot find remote branches" + status <- cmdStatus + return + } + + currentBranchCommit, err := repo.CommitObject(headReference.Hash()) + if err != nil { + cmdStatus.Error = true + cmdStatus.Message = err.Error() + status <- cmdStatus + return + } + + type remoteStatus struct { + ahead int + behind int + err error + } + + remotesStatus := make(map[string]remoteStatus) + + for _, remote := range remotes { + remoteName := remote.Config().Name + + remoteRevision, err := repo.ResolveRevision(plumbing.Revision(fmt.Sprintf("%v/%v", remoteName, headReference.Name().Short()))) + if err != nil { + remotesStatus[remoteName] = remoteStatus{ + err: err, + } + continue + } + + remoteBranchCommit, err := repo.CommitObject(*remoteRevision) + if err != nil { + remotesStatus[remoteName] = remoteStatus{ + err: err, + } + continue + } + + status := remoteStatus{ + ahead: findNumberOfCommitDiffs(currentBranchCommit, remoteBranchCommit), + behind: findNumberOfCommitDiffs(remoteBranchCommit, currentBranchCommit), + } + if status.ahead > 0 || status.behind > 0 { + cmdStatus.Changed = true + } + remotesStatus[remoteName] = status + + } + cmdStatus.Message = fmt.Sprintf("branch %v", headReference.Name().Short()) + for remoteName, status := range remotesStatus { + if status.err != nil { + cmdStatus.Message = fmt.Sprintf("%v - ( | %v | problem: %v )", cmdStatus.Message, remoteName, status.err.Error()) + continue + } + cmdStatus.Message = fmt.Sprintf("%v - ( | %v | \u2191%v \u2193%v )", cmdStatus.Message, remoteName, status.ahead, status.behind) + } + + status <- cmdStatus +} diff --git a/commands/status_cmd_test.go b/commands/status_cmd_test.go new file mode 100644 index 0000000..c362014 --- /dev/null +++ b/commands/status_cmd_test.go @@ -0,0 +1,403 @@ +package commands + +import ( + "fmt" + "os" + "testing" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-billy/v5/osfs" + "github.com/go-git/go-git/v5" + gitcfg "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/cache" + "github.com/go-git/go-git/v5/storage/filesystem" + "github.com/go-git/go-git/v5/storage/memory" + "gitlab.com/revalus/grm/config" +) + +type TestDir struct { + rootFS billy.Filesystem + baseRepository struct { + fileSystem billy.Filesystem + repo *git.Repository + } +} + +func getTestDirForTests() TestDir { + + checkError := func(err error) { + if err != nil { + fmt.Printf("During preparing repositories, an error occurred: %v\n", err) + os.Exit(4) + } + } + + baseTMPPath := fmt.Sprintf("%v/grmTest", os.TempDir()) + if _, ok := os.Stat(baseTMPPath); ok != nil { + err := os.Mkdir(baseTMPPath, 0777) + checkError(err) + } + + tmpDir, err := os.MkdirTemp(baseTMPPath, "*") + checkError(err) + + baseFileSystem := osfs.New(tmpDir) + + initRepositoryFileSystem, err := baseFileSystem.Chroot("worktree") + checkError(err) + + directoryForGitMetadata, err := initRepositoryFileSystem.Chroot(".git") + checkError(err) + + repository, err := git.Init(filesystem.NewStorage(directoryForGitMetadata, cache.NewObjectLRUDefault()), initRepositoryFileSystem) + checkError(err) + + fileForFirstCommit, err := initRepositoryFileSystem.Create("TestFile.txt") + checkError(err) + + _, err = fileForFirstCommit.Write([]byte("foo-conent")) + checkError(err) + + repositoryWorkTree, err := repository.Worktree() + checkError(err) + + repositoryWorkTree.Add(fileForFirstCommit.Name()) + _, err = repositoryWorkTree.Commit("First commit", &git.CommitOptions{}) + + checkError(err) + + return TestDir{ + baseRepository: struct { + fileSystem billy.Filesystem + repo *git.Repository + }{ + fileSystem: initRepositoryFileSystem, + repo: repository, + }, + rootFS: baseFileSystem, + } + +} + +func unexpectedError(err error, t *testing.T) { + if err != nil { + t.Errorf("Unexpected error %v", err) + } +} + +func makeCommit(wk *git.Worktree, commitMessage string, fileName string) error { + + wk.Commit(commitMessage, &git.CommitOptions{}) + return nil +} + +func TestIfBranchesAreEqual(t *testing.T) { + tmpDirWithInitialRepository := getTestDirForTests() + + fakeLocalRepo, err := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{ + URL: tmpDirWithInitialRepository.baseRepository.fileSystem.Root(), + }) + unexpectedError(err, t) + + currentReference, err := fakeLocalRepo.Head() + unexpectedError(err, t) + + remote, err := fakeLocalRepo.Remote("origin") + unexpectedError(err, t) + + remoteRevision, err := fakeLocalRepo.ResolveRevision(plumbing.Revision(fmt.Sprintf("%v/%v", remote.Config().Name, currentReference.Name().Short()))) + unexpectedError(err, t) + + currentBranchCommit, err := fakeLocalRepo.CommitObject(currentReference.Hash()) + unexpectedError(err, t) + + remoteBranchCommit, err := fakeLocalRepo.CommitObject(*remoteRevision) + unexpectedError(err, t) + + result := findNumberOfCommitDiffs(currentBranchCommit, remoteBranchCommit) + if result != 0 { + t.Errorf("Expected to get 0 changes, instead of this got %v", result) + } +} + +func TestIfCurrentBranchIsAbove(t *testing.T) { + + tmpDirWithInitialRepository := getTestDirForTests() + tmpFileSystemForLocalRepository := memfs.New() + fakeLocalRepo, err := git.Clone(memory.NewStorage(), tmpFileSystemForLocalRepository, &git.CloneOptions{ + URL: tmpDirWithInitialRepository.baseRepository.fileSystem.Root(), + }) + unexpectedError(err, t) + + localWorktree, err := fakeLocalRepo.Worktree() + unexpectedError(err, t) + + err = makeCommit(localWorktree, "commit 1", "Commit1") + unexpectedError(err, t) + + localReference, err := fakeLocalRepo.Head() + unexpectedError(err, t) + + remoteConnection, err := fakeLocalRepo.Remote("origin") + unexpectedError(err, t) + + remoteRevision, err := fakeLocalRepo.ResolveRevision(plumbing.Revision(fmt.Sprintf("%v/%v", remoteConnection.Config().Name, localReference.Name().Short()))) + unexpectedError(err, t) + + currentBranchCommit, err := fakeLocalRepo.CommitObject(localReference.Hash()) + unexpectedError(err, t) + + remoteBranchCommit, err := fakeLocalRepo.CommitObject(*remoteRevision) + unexpectedError(err, t) + + result := findNumberOfCommitDiffs(currentBranchCommit, remoteBranchCommit) + if result != 1 { + t.Errorf("Expected to get 1 changes, instead of this got %v", result) + } + + for i := 1; i < 15; i++ { + err = makeCommit(localWorktree, fmt.Sprintf("Commit +%v", i), fmt.Sprintf("Commit%v", i)) + unexpectedError(err, t) + } + + localReference, err = fakeLocalRepo.Head() + unexpectedError(err, t) + + currentBranchCommit, err = fakeLocalRepo.CommitObject(localReference.Hash()) + unexpectedError(err, t) + + result = findNumberOfCommitDiffs(currentBranchCommit, remoteBranchCommit) + if result != 15 { + t.Errorf("Expected to get 5 changes, instead of this got %v", result) + } +} + +func TestCommandRepositoryDoesNotExists(t *testing.T) { + + tmpDirWithInitialRepository := getTestDirForTests() + + fileSystemForLocalRepo, err := tmpDirWithInitialRepository.rootFS.Chroot("NotMatterValue") + unexpectedError(err, t) + + directoryForLocalRepoMetadata, err := fileSystemForLocalRepo.Chroot(".git") + unexpectedError(err, t) + + storageForTestRepo := filesystem.NewStorage(directoryForLocalRepoMetadata, cache.NewObjectLRUDefault()) + _, err = git.Clone(storageForTestRepo, fileSystemForLocalRepo, &git.CloneOptions{ + URL: tmpDirWithInitialRepository.baseRepository.fileSystem.Root(), + }) + unexpectedError(err, t) + + sc := StatusChecker{ + workspace: tmpDirWithInitialRepository.rootFS.Root(), + } + + repoCfg := config.RepositoryConfig{ + Name: "test", + Src: tmpDirWithInitialRepository.baseRepository.fileSystem.Root(), + Dest: tmpDirWithInitialRepository.rootFS.Root(), + } + + ch := make(chan CommandStatus) + go sc.Command(repoCfg, ch) + repoStatus := <-ch + expectedMessage := "repository does not exist" + + if !repoStatus.Error { + t.Errorf("Expected error") + } + if repoStatus.Changed { + t.Errorf("Unexpected change value") + } + if repoStatus.Message != expectedMessage { + t.Errorf("Expected to get \"%v\", instead of this got \"%v\"", expectedMessage, repoStatus.Message) + } +} + +func TestCommandRepositoryNoRemoteBranch(t *testing.T) { + + tmpDirWithInitialRepository := getTestDirForTests() + dirNameForLocalRepository := "testRepo" + fileSystemForLocalRepo, err := tmpDirWithInitialRepository.rootFS.Chroot(dirNameForLocalRepository) + unexpectedError(err, t) + + directoryForLocalRepoMetadata, err := fileSystemForLocalRepo.Chroot(".git") + unexpectedError(err, t) + + storageForTestRepo := filesystem.NewStorage(directoryForLocalRepoMetadata, cache.NewObjectLRUDefault()) + fakeLocalRepository, err := git.Clone(storageForTestRepo, fileSystemForLocalRepo, &git.CloneOptions{ + URL: tmpDirWithInitialRepository.baseRepository.fileSystem.Root(), + }) + unexpectedError(err, t) + + err = fakeLocalRepository.DeleteRemote("origin") + unexpectedError(err, t) + + sc := StatusChecker{ + workspace: tmpDirWithInitialRepository.rootFS.Root(), + } + + repoCfg := config.RepositoryConfig{ + Name: "test", + Src: tmpDirWithInitialRepository.baseRepository.fileSystem.Root(), + Dest: dirNameForLocalRepository, + } + + ch := make(chan CommandStatus) + go sc.Command(repoCfg, ch) + repoStatus := <-ch + expectedMessage := "cannot find remote branches" + + if !repoStatus.Error { + t.Errorf("Expected error") + } + if repoStatus.Changed { + t.Errorf("Unexpected change value") + } + + if repoStatus.Message != expectedMessage { + t.Errorf("Expected to get \"%v\", instead of this got \"%v\"", expectedMessage, repoStatus.Message) + } +} + +func TestCommandAllCorrect(t *testing.T) { + + tmpDirWithInitialRepository := getTestDirForTests() + dirNameForLocalRepository := "testRepo" + fileSystemForLocalRepo, err := tmpDirWithInitialRepository.rootFS.Chroot(dirNameForLocalRepository) + unexpectedError(err, t) + + directoryForLocalRepoMetadata, err := fileSystemForLocalRepo.Chroot(".git") + unexpectedError(err, t) + + storageForTestRepo := filesystem.NewStorage(directoryForLocalRepoMetadata, cache.NewObjectLRUDefault()) + fakeLocalRepository, err := git.Clone(storageForTestRepo, fileSystemForLocalRepo, &git.CloneOptions{ + URL: tmpDirWithInitialRepository.baseRepository.fileSystem.Root(), + }) + unexpectedError(err, t) + + sc := StatusChecker{ + workspace: tmpDirWithInitialRepository.rootFS.Root(), + } + repoCfg := config.RepositoryConfig{ + Name: "test", + Src: tmpDirWithInitialRepository.baseRepository.fileSystem.Root(), + Dest: dirNameForLocalRepository, + } + + ch := make(chan CommandStatus) + + go sc.Command(repoCfg, ch) + repoStatus := <-ch + expectedMessage := "branch master - ( | origin | \u21910 \u21930 )" + + if repoStatus.Error { + t.Errorf("Unexpected error") + t.Errorf("Message %v", repoStatus.Message) + } + if repoStatus.Changed { + t.Errorf("Expeected that changed will be true") + } + if repoStatus.Message != expectedMessage { + t.Errorf("Expected to get \"%v\", instead of this got \"%v\"", expectedMessage, repoStatus.Message) + } + + fakeLocalWorkTree, err := fakeLocalRepository.Worktree() + unexpectedError(err, t) + + err = makeCommit(fakeLocalWorkTree, "commit 1", "Commit1") + unexpectedError(err, t) + + go sc.Command(repoCfg, ch) + repoStatus = <-ch + expectedMessage = "branch master - ( | origin | \u21911 \u21930 )" + + if repoStatus.Message != expectedMessage { + t.Errorf("Expected to get \"%v\", instead of this got \"%v\"", expectedMessage, repoStatus.Message) + } + if repoStatus.Error { + t.Errorf("Unexpected error") + t.Errorf("Message %v", repoStatus.Message) + } + if !repoStatus.Changed { + t.Errorf("Expeected that changed will be true") + } + if repoStatus.Message != expectedMessage { + t.Errorf("Expected to get \"%v\", instead of this got \"%v\"", expectedMessage, repoStatus.Message) + } +} + +func TestCommandMultiRemote(t *testing.T) { + + tmpDirWithInitialRepository := getTestDirForTests() + dirNameForLocalRepository := "testRepo" + fileSystemForLocalRepo, err := tmpDirWithInitialRepository.rootFS.Chroot(dirNameForLocalRepository) + unexpectedError(err, t) + + directoryForLocalRepoMetadata, err := fileSystemForLocalRepo.Chroot(".git") + unexpectedError(err, t) + + storageForTestRepo := filesystem.NewStorage(directoryForLocalRepoMetadata, cache.NewObjectLRUDefault()) + fakeLocalRepository, err := git.Clone(storageForTestRepo, fileSystemForLocalRepo, &git.CloneOptions{ + URL: tmpDirWithInitialRepository.baseRepository.fileSystem.Root(), + }) + unexpectedError(err, t) + + fakeLocalRepository.CreateRemote(&gitcfg.RemoteConfig{ + Name: "subremote", + URLs: []string{tmpDirWithInitialRepository.baseRepository.fileSystem.Root()}, + }) + + fakeLocalRepository.Fetch(&git.FetchOptions{ + RemoteName: "subremote", + }) + + sc := StatusChecker{ + workspace: tmpDirWithInitialRepository.rootFS.Root(), + } + repoCfg := config.RepositoryConfig{ + Name: "test", + Src: tmpDirWithInitialRepository.baseRepository.fileSystem.Root(), + Dest: dirNameForLocalRepository, + } + + ch := make(chan CommandStatus) + + go sc.Command(repoCfg, ch) + repoStatus := <-ch + expectedMessage := "branch master - ( | origin | \u21910 \u21930 ) - ( | subremote | \u21910 \u21930 )" + + if repoStatus.Error { + t.Errorf("Unexpected error") + t.Errorf("Message %v", repoStatus.Message) + } + if repoStatus.Changed { + t.Errorf("Expeected that changed will be true") + } + if repoStatus.Message != expectedMessage { + t.Errorf("Expected to get \"%v\", instead of this got \"%v\"", expectedMessage, repoStatus.Message) + } + + fakeLocalWorkTree, err := fakeLocalRepository.Worktree() + unexpectedError(err, t) + + err = makeCommit(fakeLocalWorkTree, "commit 1", "Commit1") + unexpectedError(err, t) + + go sc.Command(repoCfg, ch) + repoStatus = <-ch + expectedMessage = "branch master - ( | origin | \u21911 \u21930 ) - ( | subremote | \u21911 \u21930 )" + + if repoStatus.Error { + t.Errorf("Unexpected error") + t.Errorf("Message %v", repoStatus.Message) + } + if !repoStatus.Changed { + t.Errorf("Expeected that changed will be true") + } + if repoStatus.Message != expectedMessage { + t.Errorf("Expected to get \"%v\", instead of this got \"%v\"", expectedMessage, repoStatus.Message) + } +} diff --git a/config/cmd.go b/config/cmd.go index 00f50f5..bec1841 100644 --- a/config/cmd.go +++ b/config/cmd.go @@ -23,6 +23,7 @@ func ParseCliArguments(name, description string, arguments []string) (CliArgumen parser := argparse.NewParser(name, description) syncCMD := parser.NewCommand("sync", "Synchronize repositories with remote branches, if the repository does not exist, clone it. (If pulling is not possible, the repository will be fetched)") + statusCMD := parser.NewCommand("status", "Get information about repositories") configFile := parser.String("c", "config-file", &argparse.Options{ Default: getDefaultConfigDir(), @@ -38,16 +39,19 @@ func ParseCliArguments(name, description string, arguments []string) (CliArgumen Default: false, Help: "Turn off color printing", }) + if err := parser.Parse(arguments); err != nil { - return CliArguments{}, err + return CliArguments{}, errors.New(parser.Usage("Please follow this help")) } - if !syncCMD.Happened() && !(*version) { + + if !syncCMD.Happened() && !(*version) && !statusCMD.Happened() { return CliArguments{}, errors.New(errNoCommand) } return CliArguments{ ConfigurationFile: *configFile, Sync: syncCMD.Happened(), + Status: statusCMD.Happened(), Version: *version, Color: !(*color), }, nil diff --git a/config/structures.go b/config/structures.go index 92f710e..f6a8bca 100644 --- a/config/structures.go +++ b/config/structures.go @@ -14,6 +14,7 @@ type RepositoryConfig struct { type CliArguments struct { ConfigurationFile string Sync bool + Status bool Version bool Color bool } diff --git a/go.mod b/go.mod index 1c858ea..2896766 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.17 require ( github.com/akamensky/argparse v1.3.1 + github.com/go-git/go-billy/v5 v5.3.1 github.com/go-git/go-git/v5 v5.4.2 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) @@ -14,7 +15,6 @@ require ( github.com/acomagu/bufpipe v1.0.3 // indirect github.com/emirpasic/gods v1.12.0 // indirect github.com/go-git/gcfg v1.5.0 // indirect - github.com/go-git/go-billy/v5 v5.3.1 // indirect github.com/google/go-cmp v0.5.6 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect