--- /dev/null
+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.
--- /dev/null
+.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
--- /dev/null
+// 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)
+ }
+}
+
--- /dev/null
+// 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 }
+}
+
--- /dev/null
+// 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
+}
+
--- /dev/null
+// 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{}
+}
+
--- /dev/null
+// 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
+}
+
--- /dev/null
+// 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)
+}
+
--- /dev/null
+// 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")
+}
+
--- /dev/null
+// 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%"
+)
+