From 1c7f9e1936b5baf87544ab925165ea073a890013 Mon Sep 17 00:00:00 2001 From: Andrew Sichevoi Date: Tue, 15 May 2018 12:29:23 +0300 Subject: [PATCH] initial commit --- LICENSE | 21 +++++ Makefile | 50 +++++++++++ main.go | 113 +++++++++++++++++++++++ src/git_purged/branches.go | 47 ++++++++++ src/git_purged/git.go | 74 +++++++++++++++ src/git_purged/git_external_command.go | 54 +++++++++++ src/git_purged/git_test-helpers.go | 33 +++++++ src/git_purged/git_test-mock.go | 17 ++++ src/git_purged/git_test.go | 119 +++++++++++++++++++++++++ version.go.template | 11 +++ 10 files changed, 539 insertions(+) create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 main.go create mode 100644 src/git_purged/branches.go create mode 100644 src/git_purged/git.go create mode 100644 src/git_purged/git_external_command.go create mode 100644 src/git_purged/git_test-helpers.go create mode 100644 src/git_purged/git_test-mock.go create mode 100644 src/git_purged/git_test.go create mode 100644 version.go.template diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a0db406 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Andrew Sichevoi (http://thekondor.net) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b2ee6b0 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +.PHONY: default build full_build test + +export GOPATH:=${PWD} + +BUILD_DIR=${PWD}/build +APP_NAME=git-purged +BUILD_BIN=${BUILD_DIR}/${APP_NAME} +VERSION_AUTOGEN_FN=version_autogen.go +BUILD_VERSION_FN=${BUILD_DIR}/version_autogen.go + +default: build + +build: _build_dir _version_file + @echo - Build "${APP_NAME}" as '${BUILD_BIN}' + @go build -v -o ${BUILD_BIN} ./*.go + +full_build: test build + +test: _test_deps + @echo Run tests for supplimentary code + @go test -v git_purged + + +_test_deps: + @echo Download test dependencies + @go get -v -d -t ./src/git_purged + +_build_dir: + @echo - Prepare build directory + @test -d ${BUILD_DIR} || mkdir ${BUILD_DIR} + +_version_file: _build_dir + @echo - Prepare build version + @cp -f version.go.template ${BUILD_VERSION_FN} + + @echo - Collecting build version details + $(eval GIT_ORIGIN:=$(shell git config --get remote.origin.url || (echo -n local/; git rev-parse --abbrev-ref HEAD) )) + @sed -i -e 's@%BUILD_GIT_ORIGIN%@${GIT_ORIGIN}@g' ${BUILD_VERSION_FN} + $(eval GIT_COMMIT:=$(shell git log -1 --format=%h)) + @sed -i -e 's@%BUILD_GIT_COMMIT%@${GIT_COMMIT}@g' ${BUILD_VERSION_FN} + $(eval BUILD_DATE:=$(shell date +%s)) + @sed -i -e 's@%BUILD_DATE%@${BUILD_DATE}@g' ${BUILD_VERSION_FN} + @echo - Build version ORIGIN: ${GIT_ORIGIN}, COMMIT: ${GIT_COMMIT}, DATE: ${BUILD_DATE} + + @ln -sf ${BUILD_VERSION_FN} ./${VERSION_AUTOGEN_FN} + +clean: + @echo - Clean build artefacts up + @rm -rf ${BUILD_DIR} + @test -f ./${VERSION_AUTOGEN_FN} && unlink ./${VERSION_AUTOGEN_FN} || /bin/true diff --git a/main.go b/main.go new file mode 100644 index 0000000..93bc293 --- /dev/null +++ b/main.go @@ -0,0 +1,113 @@ +// This file is a part of 'git-purged' tool, https://thekondor.net + +package main + +import ( + "git_purged" + "os" + "fmt" + "flag" + logger "log" +) + +type App struct { + git *git_purged.Git + branches git_purged.Branches + log *logger.Logger +} + +func NewApp(git *git_purged.Git, log *logger.Logger) App { + return App{ + git : git, + branches : git_purged.NewBranches(*git), + log : log, + } +} + +func (self App) PrintPurgedBranches(pruneBefore bool) { + if pruneBefore { + self.pruneRemoteOrigin() + } + + goneBranchNames, err := self.branches.ListGone() + if nil != err { + self.log.Fatalf("Failed to list purged branches: %v\n", err) + } + + self.list(goneBranchNames) +} + +func (self App) PrintAliveBranches(pruneBefore bool) { + if pruneBefore { + self.pruneRemoteOrigin() + } + + nonGoneBranchNames, err := self.branches.ListNonGone() + if nil != err { + self.log.Fatalf("Failed to list non-purged branches: %v\n", err) + } + + self.list(nonGoneBranchNames) +} + +func (self App) list(branchNames []string) { + if 0 == len(branchNames) { + self.log.Printf("No branches to show. Didn't you forget to call 'git fetch' before?") + return + } + + for _, name := range branchNames { + fmt.Println(name) + } +} + +func (self App) pruneRemoteOrigin() { + if err := self.git.PruneRemoteOrigin(); nil != err { + self.log.Fatalf("Failed to prune remote origin: %v\n", err) + } +} + +func NewGit(log *logger.Logger) *git_purged.Git{ + git, err := git_purged.NewGit(git_purged.NewGitExternalCommand()) + if nil == err { + return git + } + + if git_purged.GitNotAvailableErr == err { + log.Fatal("Error: 'git' command is not available or misconfigured"); + } else { + log.Fatalf("Error: 'git' command cannot be used (%v)\n", err) + } + + return nil +} + +func main() { + log := logger.New(os.Stdout, "[git-purged] ", 0) + + inverseFlag := flag.Bool("inverse", false, "Inverse output by printing alive branches only. Optional.") + skipPruneFlag := flag.Bool("skip-prune", false, "Skip prunning of remote's origin to calculate purged branches before listing. Optional.") + helpFlag := flag.Bool("help", false, "Show this help.") + + flag.Parse() + if *helpFlag { + fmt.Printf("git-purged - subcommand to list purged (already removed on a remote master) branches.\n") + fmt.Printf(" build: %s#%s (%s)\n\n", BuildDate, BuildGitCommit, BuildGitOrigin) + flag.PrintDefaults() + fmt.Println() + log.Fatalf("No action requested") + } + + git := NewGit(log) + if !git.IsRepository() { + log.Fatalf("Current directory is not a valid working git tree") + } + + app := NewApp(git, log) + if *inverseFlag { + app.PrintAliveBranches(*skipPruneFlag) + } else { + app.PrintPurgedBranches(*skipPruneFlag) + } +} + diff --git a/src/git_purged/branches.go b/src/git_purged/branches.go new file mode 100644 index 0000000..29705af --- /dev/null +++ b/src/git_purged/branches.go @@ -0,0 +1,47 @@ +// This file is a part of 'git-purged' tool, http://thekondor.net + +package git_purged + +type Branches struct { + git Git +} + +const GoneTrackName = "[gone]" + +func filter(branches []GitBranch, pred func(GitBranch) bool) []string { + filtered := []string{} + + for _, branch := range branches { + if pred(branch) { + filtered = append(filtered, branch.Name) + } + } + + return filtered +} + +func (self Branches) list(pred func(GitBranch) bool) ([]string, error) { + allBranches, err := self.git.ListLocalBranches() + if nil != err { + return nil, err + } + + return filter(allBranches, pred), nil +} + +func (self Branches) ListGone() ([]string, error) { + return self.list(func(branch GitBranch) bool { + return GoneTrackName == branch.Track + }) +} + +func (self Branches) ListNonGone() ([]string, error) { + return self.list(func(branch GitBranch) bool { + return GoneTrackName != branch.Track + }) +} + +func NewBranches(git Git) Branches { + return Branches{ git : git } +} + diff --git a/src/git_purged/git.go b/src/git_purged/git.go new file mode 100644 index 0000000..77c741c --- /dev/null +++ b/src/git_purged/git.go @@ -0,0 +1,74 @@ +// This file is a part of 'git-purged' tool, http://thekondor.net + +package git_purged + +import ( + "fmt" + "strings" +) + +type Git struct { + binary string + command ExternalCommand +} + +type GitBranch struct { + Track string + Name string +} + +var ( + GitNotAvailableErr = fmt.Errorf("GIT: 'git' is not available in PATH or misconfigured") +) + +type ExternalCommand interface { + Run(args ...string) (stdout []string, err error) +} + +func NewGit(gitCommand ExternalCommand) (*Git, error) { + _, err := gitCommand.Run("--version") + + if nil != err { + return nil, GitNotAvailableErr + } + + return &Git{ command : gitCommand }, nil +} + +func (self Git) IsRepository() bool { + stdout, err := self.command.Run("rev-parse", "--is-inside-work-tree") + if nil != err { + return false + } + + return len(stdout) > 0 && "true" == stdout[0] +} + +func (self Git) PruneRemoteOrigin() error { + _, err := self.command.Run("remote", "prune", "origin") + if nil != err { + return fmt.Errorf("Git: failed to prune remote origin's references: %v", err) + } + + return nil +} + +func (self Git) ListLocalBranches() ([]GitBranch, error) { + stdout, err := self.command.Run("branch", "-vv", "--format=%(upstream:track):%(refname:lstrip=2)") + if nil != err { + return nil, fmt.Errorf("Git: failed to execute branch listing: %v", err) + } + + var parsed []GitBranch + for _, line := range stdout { + parsedLine := strings.Split(line, ":") + if 2 != len(parsedLine) { + return nil, fmt.Errorf("Git: invalid local branches format: %s", line) + } + + parsed = append(parsed, GitBranch{ parsedLine[0], parsedLine[1] }) + } + + return parsed, nil +} + diff --git a/src/git_purged/git_external_command.go b/src/git_purged/git_external_command.go new file mode 100644 index 0000000..1db1237 --- /dev/null +++ b/src/git_purged/git_external_command.go @@ -0,0 +1,54 @@ +// This file is a part of 'git-purged' tool, http://thekondor.net + +package git_purged + +import ( + "fmt" + "os/exec" + "bufio" + "strings" +) + +type GitExternalCommand struct { +} + +func collectOutput(scanner *bufio.Scanner) []string { + var output []string + for scanner.Scan() { + output = append(output, scanner.Text()) + } + + return output +} + +func (self GitExternalCommand) Run(args ...string) ([]string, error) { + cmd := exec.Command("git", args...) + + stdoutPipe, err := cmd.StdoutPipe() + if nil != err { + return nil, fmt.Errorf("Git: failed to obtain stdout pipe for stdout: %v", err) + } + + stderrPipe, err := cmd.StderrPipe() + if nil != err { + return nil, fmt.Errorf("Git: failed to obtain stderr pipe for stderr: %v", err) + } + + cmd.Start() + + stdout := collectOutput(bufio.NewScanner(stdoutPipe)) + stderr := collectOutput(bufio.NewScanner(stderrPipe)) + + cmd.Wait() + + if len(stderr) > 0 { + return nil, fmt.Errorf("Git: '%s' failed: '%s'", strings.Join(args, " "), strings.Join(stderr, "\n")) + } + + return stdout, nil +} + +func NewGitExternalCommand() GitExternalCommand { + return GitExternalCommand{} +} + diff --git a/src/git_purged/git_test-helpers.go b/src/git_purged/git_test-helpers.go new file mode 100644 index 0000000..c3a2263 --- /dev/null +++ b/src/git_purged/git_test-helpers.go @@ -0,0 +1,33 @@ +// This file is a part of 'git-purged' tool, http://thekondor.net + +package git_purged + +import ( + "github.com/stretchr/testify/mock" +) + +func arrayOfStrings(args mock.Arguments, index int) []string { + arg := args.Get(0) + arrayOfString, ok := arg.([]string) + if !ok { + panic("Cannot cast to array of strings") + } + + return arrayOfString +} + +var EmptyStdOut = []string{} +var EmptyError error = nil + +func withArgs(args ...string) []string { + return args +} + +func stdOut(args ...string) []string { + return args +} + +func anyArgument(interface{}) bool { + return true +} + diff --git a/src/git_purged/git_test-mock.go b/src/git_purged/git_test-mock.go new file mode 100644 index 0000000..e9426a2 --- /dev/null +++ b/src/git_purged/git_test-mock.go @@ -0,0 +1,17 @@ +// This file is a part of 'git-purged' tool, http://thekondor.net + +package git_purged + +import ( + "github.com/stretchr/testify/mock" +) + +type GitExternalCommandMock struct { + mock.Mock +} + +func (self *GitExternalCommandMock) Run(args ...string) ([]string, error) { + mockArgs := self.Called(args) + return arrayOfStrings(mockArgs, 0), mockArgs.Error(1) +} + diff --git a/src/git_purged/git_test.go b/src/git_purged/git_test.go new file mode 100644 index 0000000..c4bc944 --- /dev/null +++ b/src/git_purged/git_test.go @@ -0,0 +1,119 @@ +// This file is a part of 'git-purged' tool, http://thekondor.net + +package git_purged + +import ( + "testing" + "errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestGit_CreationFailsOnAvailabilityCheck(t *testing.T) { + mockGit := new(GitExternalCommandMock) + mockGit.On("Run", withArgs("--version")). + Return( + EmptyStdOut, + errors.New("Command not found"), + ) + + sut, err := NewGit(mockGit) + + require.Nil(t, sut, "Nil is returned on error") + assert.NotNil(t, err, "Error is returned on git non available") + assert.Contains(t, err.Error(), "not available") +} + +func mockGitCommand() *GitExternalCommandMock { + mockGit := new(GitExternalCommandMock) + mockGit.On("Run", mock.MatchedBy(anyArgument)). + Return( + EmptyStdOut, EmptyError, + ).Once() + + return mockGit +} + +func prepareSut(t *testing.T) (*Git, *GitExternalCommandMock) { + mockGit := mockGitCommand() + sut, err := NewGit(mockGit) + + assert.Nil(t, err) + assert.NotNil(t, sut) + + return sut, mockGit +} + +func TestGit_CreationSucceededOnAvailabiltyCheck(t *testing.T) { + mockGit := new(GitExternalCommandMock) + mockGit.On("Run", withArgs("--version")). + Return( + EmptyStdOut, EmptyError, + ) + + sut, err := NewGit(mockGit) + + assert.NotNil(t, sut, "Valid object is returned") + require.Nil(t, err) +} + +func TestGit_ErrorIsPassedOver_OnFailedPrune(t *testing.T) { + sut, mockGit := prepareSut(t) + + mockGit.On("Run", withArgs("remote", "prune", "origin")). + Return( + EmptyStdOut, errors.New("test:prune failed"), + ).Once() + + err := sut.PruneRemoteOrigin() + + require.NotNil(t, err, "Error is returned on prune remote origin") + require.Contains(t, err.Error(), "test:prune failed") +} + +func TestGit_ErrorIsPassedOver_OnFailedBranchListing(t *testing.T) { + sut, mockGit := prepareSut(t) + + mockGit.On("Run", withArgs("branch", "-vv", "--format=%(upstream:track):%(refname:lstrip=2)")). + Return( + EmptyStdOut, errors.New("test:branch listing failed"), + ).Once() + + _, err := sut.ListLocalBranches() + + require.NotNil(t, err, "Error is returned on listing local branches") + require.Contains(t, err.Error(), "test:branch listing failed") +} + +func TestGit_BranchesAreReturned_OnBranchListing(t *testing.T) { + sut, mockGit := prepareSut(t) + + mockGit.On("Run", mock.MatchedBy(anyArgument)). + Return( + stdOut( + ":refs/heads/branch-1", + "[gone]:refs/heads/branch-2", + ), EmptyError, + ).Once() + + branches, _ := sut.ListLocalBranches() + require.Len(t, branches, 2, "Amount of listed branches is recognized") +} + +func TestGit_GoneBranchesAreRecognized_OnBranchListing(t *testing.T) { + sut, mockGit := prepareSut(t) + + mockGit.On("Run", mock.MatchedBy(anyArgument)). + Return( + stdOut( + ":refs/heads/branch-1", + "[gone]:refs/heads/branch-2", + ), EmptyError, + ).Once() + + branches, _ := sut.ListLocalBranches() + assert.NotEmpty(t, branches) + require.Contains(t, branches, GitBranch{"[gone]", "refs/heads/branch-2"}, "Gone branch is recognized") +} + diff --git a/version.go.template b/version.go.template new file mode 100644 index 0000000..de42e7c --- /dev/null +++ b/version.go.template @@ -0,0 +1,11 @@ +// This file is a part of 'git-purged' tool, http://thekondor.net +// Generated automatically. Should not be edited manually. + +package main + +const ( + BuildGitOrigin = "%BUILD_GIT_ORIGIN%" + BuildGitCommit = "%BUILD_GIT_COMMIT%" + BuildDate = "%BUILD_DATE%" +) + -- 2.20.1