diff --git a/README.md b/README.md index c199d65..05113cd 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,7 @@ export TRACKER_API_TOKEN="YOUR PIVOTAL TRACKER API TOKEN" # https://www.pivotalt 2. Setup the homebrew tap and install git-story ```sh -$ brew tap git-story-branch/tap -$ brew install git-story +brew install git-story-branch/tap/git-story ``` ## Usage @@ -31,21 +30,31 @@ $ brew install git-story `git-story` is useful when your git branch name ends in the story ID of the Pivotal Tracker story you're working on: ```sh -$ git checkout WIP-some-story-1234567890 +git checkout WIP-some-story-1234567890 ``` ### git story-view : view the details of a story ```sh -$ git story-view -State: delivered -Description goes here... +git story-view +# Example output: +# State: delivered +# Description goes here... ``` ### git story-open : open the story URL in your default browser ```sh -$ git story-open +git story-open +``` + +### git story-sweep : delete all local branches corresponding to accepted stories, and report any errors in deleting + +```sh +git story-sweep +# Example output: +# Could not delete 'WIP-accepted-branch-#987654321', error: Cannot delete branch 'WIP-accepted-branch-#987654321' checked out at '/path' # This is an expected error when you're deleting the current branch +# Deleted 'accepted-feature-branch-#123456789' ``` ## Contributing to Git-Story @@ -63,4 +72,4 @@ To run: # assuming the library has been built and you're in a branch that has a story ID at the end... ./git-story-view ./git-story-open -``` \ No newline at end of file +``` diff --git a/adapters/fixtures/git b/adapters/fixtures/git index c0bebc5..0372aab 100755 --- a/adapters/fixtures/git +++ b/adapters/fixtures/git @@ -5,3 +5,13 @@ args=$* if [ "${args}" = "rev-parse --abbrev-ref HEAD" ]; then echo "current-branch-123456789" fi + +if [ "${args}" = "--no-pager branch --format %(refname:short)" ]; then + echo "main" + echo "some-branch-#123" +fi + +if [ "${args}" = "branch -D some-nonexistent-branch" ]; then + >&2 echo "error: branch 'some-nonexistent-branch' not found." + exit 1 +fi diff --git a/adapters/git.go b/adapters/git.go index 2b095d5..3493a43 100644 --- a/adapters/git.go +++ b/adapters/git.go @@ -1,6 +1,7 @@ package adapters import ( + "errors" "os/exec" "strings" ) @@ -8,12 +9,25 @@ import ( // GitRepository is an abstraction for git repositories type GitRepository struct{} -// GetBranchName gets the current branch name of the repository. -func (repo GitRepository) GetBranchName() string { +// GetCurrentBranchName gets the current branch name of the repository. +func (repo GitRepository) GetCurrentBranchName() string { output, _ := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() return strings.TrimSpace(string(output)) } +func (repo GitRepository) DeleteBranch(branchName string) error { + cmd := exec.Command("git", "branch", "-D", branchName) + output, error := cmd.CombinedOutput() + if error != nil { + return errors.New(strings.Trim(string(output), "\n")) + } + return nil +} +func (repo GitRepository) GetAllBranchNames() []string { + output, _ := exec.Command("git", "--no-pager", "branch", "--format", "%(refname:short)").Output() + return strings.Split(strings.TrimSpace(string(output)), "\n") +} + // NewRepository creates a new repository func NewRepository() *GitRepository { return &GitRepository{} diff --git a/adapters/git_test.go b/adapters/git_test.go index 96a5859..35e6fa4 100644 --- a/adapters/git_test.go +++ b/adapters/git_test.go @@ -21,7 +21,23 @@ var _ = Describe("Git", func() { }) It("should know the current branch", func() { - branchName := adapters.NewRepository().GetBranchName() + branchName := adapters.NewRepository().GetCurrentBranchName() Expect(branchName).To(Equal("current-branch-123456789")) }) + + It("should get all branch names", func() { + branchNames := adapters.NewRepository().GetAllBranchNames() + Expect(branchNames).To(Equal([]string{"main", "some-branch-#123"})) + }) + + It("should delete a branch", func() { + error := adapters.NewRepository().DeleteBranch("some-accepted-branch-123456789") + Expect(error).To(BeNil()) + }) + + It("should fail to delete when there is a nonexistent branch", func() { + error := adapters.NewRepository().DeleteBranch("some-nonexistent-branch") + Expect(error).NotTo(BeNil()) + Expect(error.Error()).To(Equal("error: branch 'some-nonexistent-branch' not found.")) + }) }) diff --git a/cli/git-story-open/main.go b/cli/git-story-open/main.go index 6d4ffd3..e2a9628 100644 --- a/cli/git-story-open/main.go +++ b/cli/git-story-open/main.go @@ -19,6 +19,6 @@ func main() { tracker := newTracker() error := usecases.OpenStory(adapters.NewRepository(), tracker, adapters.NewBrowser()) if error != nil { - fmt.Print(error) + fmt.Println(error) } } diff --git a/cli/git-story-sweep/main.go b/cli/git-story-sweep/main.go new file mode 100644 index 0000000..ffdc060 --- /dev/null +++ b/cli/git-story-sweep/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "os" + + "github.com/git-story-branch/git-story-branch/adapters" + "github.com/git-story-branch/git-story-branch/usecases" + "gopkg.in/salsita/go-pivotaltracker.v2/v5/pivotal" +) + +func newTracker() usecases.Tracker { + apiToken := os.Getenv("TRACKER_API_TOKEN") + client := pivotal.NewClient(apiToken) + return adapters.NewPivotalTracker(client.Stories) +} + +func main() { + tracker := newTracker() + results := usecases.SweepAcceptedStories(adapters.NewRepository(), tracker) + + hasError := false + for branchName, error := range results { + if error != nil { + hasError = true + fmt.Printf("Could not delete '%s', %s\n", branchName, error) + } else { + fmt.Printf("Deleted '%s'\n", branchName) + } + } + if hasError { + os.Exit(1) + } +} diff --git a/cli/git-story-view/main.go b/cli/git-story-view/main.go index 95a0fa4..a398969 100644 --- a/cli/git-story-view/main.go +++ b/cli/git-story-view/main.go @@ -19,8 +19,8 @@ func main() { tracker := newTracker() story, error := usecases.GetStory(adapters.NewRepository(), tracker) if error != nil { - fmt.Print(error) + fmt.Println(error) } else { - fmt.Printf("State: %v\n%v", story.State, story.Description) + fmt.Printf("State: %v\n%v\n", story.State, story.Description) } } diff --git a/usecases/entities.go b/usecases/entities.go new file mode 100644 index 0000000..f6faf72 --- /dev/null +++ b/usecases/entities.go @@ -0,0 +1,18 @@ +package usecases + +type Story struct { + ID int + Description string + State string + URL string +} + +type Tracker interface { + GetStory(storyID int) (*Story, error) +} + +type Repository interface { + GetCurrentBranchName() string + DeleteBranch(branchName string) error + GetAllBranchNames() []string +} diff --git a/usecases/get_story.go b/usecases/get_story.go index e0cb247..f2a7d9e 100644 --- a/usecases/get_story.go +++ b/usecases/get_story.go @@ -6,13 +6,6 @@ import ( "strconv" ) -type Story struct { - ID int - Description string - State string - URL string -} - func getPivotalTrackerTaskID(branchName string) (int, error) { re := regexp.MustCompile(`\d+$`) taskIDString := re.FindString(branchName) @@ -22,22 +15,16 @@ func getPivotalTrackerTaskID(branchName string) (int, error) { // GetStory comment func GetStory(repo Repository, tracker Tracker) (*Story, error) { - currentBranchName := repo.GetBranchName() - storyID, branchError := getPivotalTrackerTaskID(currentBranchName) + currentBranchName := repo.GetCurrentBranchName() + return GetStoryByBranchName(currentBranchName, tracker) +} + +func GetStoryByBranchName(branchName string, tracker Tracker) (*Story, error) { + storyID, branchError := getPivotalTrackerTaskID(branchName) if branchError != nil { - return nil, errors.New("Please run in branch that contains a Pivotal Tracker Story ID") + return nil, errors.New("please run in branch that contains a Pivotal Tracker Story ID") } return tracker.GetStory(storyID) } - -// Tracker comment -type Tracker interface { - GetStory(storyID int) (*Story, error) -} - -// Repository comment -type Repository interface { - GetBranchName() string -} diff --git a/usecases/get_story_test.go b/usecases/get_story_test.go index fa2189a..57607cc 100644 --- a/usecases/get_story_test.go +++ b/usecases/get_story_test.go @@ -10,7 +10,7 @@ import ( var _ = Describe("Git Tracker name translator", func() { Describe("when the current branch is a story ID", func() { It("should retrieve a Pivotal Tracker Story based on the current git branch name", func() { - mockGitRepo := MockGitRepository{branchName: "Insert Branch Name Here-#1234567890"} + mockGitRepo := &MockGitRepository{currentBranchName: "Insert Branch Name Here-#1234567890"} mockTrackerReader := MockPivotalTrackerReader{} story, error := usecases.GetStory(mockGitRepo, mockTrackerReader) @@ -21,19 +21,19 @@ var _ = Describe("Git Tracker name translator", func() { }) Describe("when the current branch is not a story ID", func() { It("should return an error", func() { - mockGitRepo := MockGitRepository{branchName: "main"} + mockGitRepo := &MockGitRepository{currentBranchName: "main"} mockTrackerReader := MockPivotalTrackerReader{} story, error := usecases.GetStory(mockGitRepo, mockTrackerReader) Expect(story).To(BeNil()) Expect(error).NotTo(BeNil()) - Expect(error.Error()).To(ContainSubstring("Please run in branch that contains a Pivotal Tracker Story ID")) + Expect(error.Error()).To(ContainSubstring("please run in branch that contains a Pivotal Tracker Story ID")) }) }) Describe("when the Tracker API returns an error", func() { It("should return an error", func() { - mockGitRepo := MockGitRepository{branchName: "Insert Branch Name Here-#1234567890"} + mockGitRepo := &MockGitRepository{currentBranchName: "Insert Branch Name Here-#1234567890"} mockTrackerReader := MockPivotalTrackerReader{isBroken: true} story, error := usecases.GetStory(mockGitRepo, mockTrackerReader) diff --git a/usecases/mocks_test.go b/usecases/mocks_test.go index 40d301e..f6255f0 100644 --- a/usecases/mocks_test.go +++ b/usecases/mocks_test.go @@ -17,11 +17,29 @@ func (browserSpy *BrowserSpy) OpenURL(URL string) (*exec.Cmd, error) { } type MockGitRepository struct { - branchName string + currentBranchName string + branchNames []string + deletedBranches []string + erroredBranches []string } -func (mockGitRepo MockGitRepository) GetBranchName() string { - return mockGitRepo.branchName +func (mockGitRepo MockGitRepository) GetCurrentBranchName() string { + return mockGitRepo.currentBranchName +} + +func (mockGitRepo MockGitRepository) GetAllBranchNames() []string { + return mockGitRepo.branchNames +} + +func (mockGitRepo *MockGitRepository) DeleteBranch(branchName string) error { + mockGitRepo.deletedBranches = append(mockGitRepo.deletedBranches, branchName) + + for _, erroredBranch := range mockGitRepo.erroredBranches { + if branchName == erroredBranch { + return errors.New("Kabooooommmmm!") + } + } + return nil } type MockPivotalTrackerReader struct { @@ -32,6 +50,22 @@ func (mockTrackerReader MockPivotalTrackerReader) GetStory(storyID int) (*usecas if mockTrackerReader.isBroken { return nil, errors.New("unable to find that story") } + if storyID == 123 { + return &usecases.Story{ + ID: 123, + Description: "Description", + State: "accepted", + URL: "https://story.com/123", + }, nil + } + if storyID == 234 { + return &usecases.Story{ + ID: 234, + Description: "Description", + State: "started", + URL: "https://story.com/234", + }, nil + } if storyID == 1234567890 { return &usecases.Story{ ID: 1234567890, diff --git a/usecases/open_story_test.go b/usecases/open_story_test.go index 2124880..8eab178 100644 --- a/usecases/open_story_test.go +++ b/usecases/open_story_test.go @@ -10,7 +10,7 @@ import ( var _ = Describe("Browser Opener use case", func() { Describe("when the current branch is a story ID", func() { It("should open the browser to the URL of the story", func() { - mockGitRepo := MockGitRepository{branchName: "Insert Branch Name Here-#1234567890"} + mockGitRepo := &MockGitRepository{currentBranchName: "Insert Branch Name Here-#1234567890"} browserSpy := &BrowserSpy{} mockTrackerReader := MockPivotalTrackerReader{} @@ -22,19 +22,19 @@ var _ = Describe("Browser Opener use case", func() { }) Describe("when the current branch is not a story ID", func() { It("should return an error", func() { - mockGitRepo := MockGitRepository{branchName: "main"} + mockGitRepo := &MockGitRepository{currentBranchName: "main"} browserSpy := &BrowserSpy{} mockTrackerReader := MockPivotalTrackerReader{} error := usecases.OpenStory(mockGitRepo, mockTrackerReader, browserSpy) Expect(error).NotTo(BeNil()) - Expect(error.Error()).To(ContainSubstring("Please run in branch that contains a Pivotal Tracker Story ID")) + Expect(error.Error()).To(ContainSubstring("please run in branch that contains a Pivotal Tracker Story ID")) }) }) Describe("when the Tracker API returns an error", func() { It("should return an error", func() { - mockGitRepo := MockGitRepository{branchName: "Insert Branch Name Here-#1234567890"} + mockGitRepo := &MockGitRepository{currentBranchName: "Insert Branch Name Here-#1234567890"} browserSpy := &BrowserSpy{} mockTrackerReader := MockPivotalTrackerReader{isBroken: true} diff --git a/usecases/sweep_accepted_stories.go b/usecases/sweep_accepted_stories.go new file mode 100644 index 0000000..b4d5a22 --- /dev/null +++ b/usecases/sweep_accepted_stories.go @@ -0,0 +1,20 @@ +package usecases + +type ResultMap map[string]error + +// SweepAcceptedStories returns a map of the branches it attempted to delete and an error if that branch was unable to be deleted +func SweepAcceptedStories(repo Repository, tracker Tracker) ResultMap { + branchErrors := make(map[string]error) + + branchNames := repo.GetAllBranchNames() + + for _, branchName := range branchNames { + story, _ := GetStoryByBranchName(branchName, tracker) + + if story != nil && story.State == "accepted" { + branchErrors[branchName] = repo.DeleteBranch(branchName) + } + } + + return branchErrors +} diff --git a/usecases/sweep_accepted_stories_test.go b/usecases/sweep_accepted_stories_test.go new file mode 100644 index 0000000..76ff287 --- /dev/null +++ b/usecases/sweep_accepted_stories_test.go @@ -0,0 +1,34 @@ +package usecases_test + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + usecases "github.com/git-story-branch/git-story-branch/usecases" +) + +var _ = Describe("Story Sweeper use case", func() { + It("should delete all branches that correspond to accepted stories", func() { + mockGitRepo := &MockGitRepository{branchNames: []string{"main", "some-accepted-story-#123", "some-wip-story-#234"}} + mockTrackerReader := MockPivotalTrackerReader{} + result := usecases.SweepAcceptedStories(mockGitRepo, mockTrackerReader) + + var resultContainsStory bool + _, resultContainsStory = result["some-accepted-story-#123"] + + Expect(resultContainsStory).To(BeTrue()) + Expect(mockGitRepo.deletedBranches).To(Equal([]string{"some-accepted-story-#123"})) + }) + + It("should return an error if the branch is unable to be deleted", func() { + mockGitRepo := &MockGitRepository{ + branchNames: []string{"main", "some-accepted-story-#123", "some-accepted-story-#789", "some-wip-story-#234"}, + erroredBranches: []string{"some-accepted-story-#123"}, + } + mockTrackerReader := MockPivotalTrackerReader{} + results := usecases.SweepAcceptedStories(mockGitRepo, mockTrackerReader) + + Expect(results["some-accepted-story-#123"]).NotTo(BeNil()) + Expect(results["some-accepted-story-#789"]).To(BeNil()) + }) +})