initial commit
authorAndrew Sichevoi <kondor@thekondor.net>
Tue, 15 May 2018 09:29:23 +0000 (12:29 +0300)
committerAndrew Sichevoi <kondor@thekondor.net>
Tue, 15 May 2018 09:29:23 +0000 (12:29 +0300)
LICENSE [new file with mode: 0644]
Makefile [new file with mode: 0644]
main.go [new file with mode: 0644]
src/git_purged/branches.go [new file with mode: 0644]
src/git_purged/git.go [new file with mode: 0644]
src/git_purged/git_external_command.go [new file with mode: 0644]
src/git_purged/git_test-helpers.go [new file with mode: 0644]
src/git_purged/git_test-mock.go [new file with mode: 0644]
src/git_purged/git_test.go [new file with mode: 0644]
version.go.template [new file with mode: 0644]

diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
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 (file)
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 (file)
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 (file)
index 0000000..29705af
--- /dev/null
@@ -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 (file)
index 0000000..77c741c
--- /dev/null
@@ -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 (file)
index 0000000..1db1237
--- /dev/null
@@ -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 (file)
index 0000000..c3a2263
--- /dev/null
@@ -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 (file)
index 0000000..e9426a2
--- /dev/null
@@ -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 (file)
index 0000000..c4bc944
--- /dev/null
@@ -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 (file)
index 0000000..de42e7c
--- /dev/null
@@ -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%"
+)
+