Repository: seachicken/gh-poi
Pull request #10 (2021-11-15 14:20:12)

Refactor to testable design

codecov[bot]: # [Codecov](https://codecov.io/gh/seachicken/gh-poi/pull/10?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka) Report
> Merging [#10](https://codecov.io/gh/seachicken/gh-poi/pull/10?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka) (2ad5cfe) into [main](https://codecov.io/gh/seachicken/gh-poi/commit/03f1463c0d16a521130b65522202724874534df2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka) (03f1463) will **increase** coverage by `38.04%`.
> The diff coverage is `87.27%`.

[![Impacted file tree graph](https://codecov.io/gh/seachicken/gh-poi/pull/10/graphs/tree.svg?width=650&height=150&src=pr&token=tcPxPgst2q&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka)](https://codecov.io/gh/seachicken/gh-poi/pull/10?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka)

```diff
@@             Coverage Diff             @@
##             main      #10       +/-   ##
===========================================
+ Coverage   43.79%   81.84%   +38.04%     
===========================================
  Files           3        3               
  Lines         379      391       +12     
===========================================
+ Hits          166      320      +154     
+ Misses        204       61      -143     
- Partials        9       10        +1     
```


| [Impacted Files](https://codecov.io/gh/seachicken/gh-poi/pull/10?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka) | Coverage Δ | |
|---|---|---|
| [main.go](https://codecov.io/gh/seachicken/gh-poi/pull/10/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka#diff-bWFpbi5nbw==) | `61.06% <80.00%> (+61.06%)` | :arrow_up: |
| [poi.go](https://codecov.io/gh/seachicken/gh-poi/pull/10/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka#diff-cG9pLmdv) | `89.49% <88.00%> (+30.78%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/seachicken/gh-poi/pull/10?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/seachicken/gh-poi/pull/10?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka). Last update [03f1463...2ad5cfe](https://codecov.io/gh/seachicken/gh-poi/pull/10?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Seito+Tanaka).


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d0dc382..57001be 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        os: [ubuntu-latest, windows-latest, macos-latest]
+        os: [ubuntu-latest, macos-latest]
     runs-on: ${{ matrix.os }}
 
     steps:
diff --git a/connmock/stub.go b/connmock/stub.go
new file mode 100644
index 0000000..75195b4
--- /dev/null
+++ b/connmock/stub.go
@@ -0,0 +1,110 @@
+package connmock
+
+import (
+	"io/ioutil"
+	"path"
+
+	"github.com/golang/mock/gomock"
+	"github.com/seachicken/gh-poi/mocks"
+)
+
+type (
+	Stub struct {
+		Conn *mocks.MockConnection
+		t    gomock.TestHelper
+	}
+
+	Times struct {
+		N int
+	}
+
+	Conf struct {
+		Times *Times
+	}
+)
+
+var (
+	fixturePath = "fixtures"
+)
+
+func Setup(ctrl *gomock.Controller) *Stub {
+	conn := mocks.NewMockConnection(ctrl)
+	return &Stub{conn, ctrl.T}
+}
+
+func NewConf(times *Times) *Conf {
+	return &Conf{
+		times,
+	}
+}
+
+func (s *Stub) GetRepoNames(path string, err error, conf *Conf) *Stub {
+	s.t.Helper()
+	configure(
+		s.Conn.
+			EXPECT().
+			GetRepoNames().
+			Return(s.readFile("gh", "repo", path), err),
+		conf,
+	)
+	return s
+}
+
+func (s *Stub) GetBrancheNames(name string, err error, conf *Conf) *Stub {
+	s.t.Helper()
+	configure(
+		s.Conn.EXPECT().
+			GetBrancheNames().
+			Return(s.readFile("git", "branch", name), err),
+		conf,
+	)
+	return s
+}
+
+func (s *Stub) GetPullRequests(path string, err error, conf *Conf) *Stub {
+	s.t.Helper()
+	configure(
+		s.Conn.
+			EXPECT().
+			GetPullRequests(gomock.Any(), gomock.Any(), gomock.Any()).
+			Return(s.readFile("gh", "pr", path), err),
+		conf,
+	)
+	return s
+}
+
+func (s *Stub) DeleteBranches(err error, conf *Conf) *Stub {
+	s.t.Helper()
+	configure(
+		s.Conn.
+			EXPECT().
+			DeleteBranches(gomock.Any()).
+			Return("", err),
+		conf,
+	)
+	return s
+}
+
+func configure(call *gomock.Call, conf *Conf) {
+	if conf == nil {
+		return
+	}
+
+	if conf.Times == nil {
+		call.AnyTimes()
+	} else {
+		call.Times(conf.Times.N)
+	}
+}
+
+func (s *Stub) readFile(command string, category string, name string) string {
+	ext := ".txt"
+	if command == "gh" {
+		ext = ".json"
+	}
+	b, err := ioutil.ReadFile(path.Join(fixturePath, command, category+"_"+name+ext))
+	if err != nil {
+		s.t.Fatalf("%v", err)
+	}
+	return string(b)
+}
diff --git a/fixtures/gh/pr_issue1Closed.json b/fixtures/gh/pr_issue1Closed.json
new file mode 100644
index 0000000..b2068a0
--- /dev/null
+++ b/fixtures/gh/pr_issue1Closed.json
@@ -0,0 +1,20 @@
+{
+  "data": {
+    "search": {
+      "issueCount": 1,
+      "edges": [
+        {
+          "node": {
+            "number": 1,
+            "url": "https://github.com/owner/repo/pull/1",
+            "state": "CLOSED",
+            "headRefName": "issue1",
+            "author": {
+              "login": "owner"
+            }
+          }
+        }
+      ]
+    }
+  }
+}
\ No newline at end of file
diff --git a/fixtures/gh/pr_issue1Merged.json b/fixtures/gh/pr_issue1Merged.json
new file mode 100644
index 0000000..b79e959
--- /dev/null
+++ b/fixtures/gh/pr_issue1Merged.json
@@ -0,0 +1,20 @@
+{
+  "data": {
+    "search": {
+      "issueCount": 1,
+      "edges": [
+        {
+          "node": {
+            "number": 1,
+            "url": "https://github.com/owner/repo/pull/1",
+            "state": "MERGED",
+            "headRefName": "issue1",
+            "author": {
+              "login": "owner"
+            }
+          }
+        }
+      ]
+    }
+  }
+}
\ No newline at end of file
diff --git a/fixtures/gh/pr_issue1Merged_issue1Closed.json b/fixtures/gh/pr_issue1Merged_issue1Closed.json
new file mode 100644
index 0000000..902029a
--- /dev/null
+++ b/fixtures/gh/pr_issue1Merged_issue1Closed.json
@@ -0,0 +1,31 @@
+{
+  "data": {
+    "search": {
+      "issueCount": 2,
+      "edges": [
+        {
+          "node": {
+            "number": 1,
+            "url": "https://github.com/owner/repo/pull/1",
+            "state": "CLOSED",
+            "headRefName": "issue1",
+            "author": {
+              "login": "owner"
+            }
+          }
+        },
+        {
+          "node": {
+            "number": 2,
+            "url": "https://github.com/owner/repo/pull/2",
+            "state": "MERGED",
+            "headRefName": "issue1",
+            "author": {
+              "login": "owner"
+            }
+          }
+        }
+      ]
+    }
+  }
+}
\ No newline at end of file
diff --git a/fixtures/gh/pr_issue1UpMerged.json b/fixtures/gh/pr_issue1UpMerged.json
new file mode 100644
index 0000000..4868e76
--- /dev/null
+++ b/fixtures/gh/pr_issue1UpMerged.json
@@ -0,0 +1,20 @@
+{
+  "data": {
+    "search": {
+      "issueCount": 1,
+      "edges": [
+        {
+          "node": {
+            "number": 1,
+            "url": "https://github.com/parent-owner/repo/pull/1",
+            "state": "MERGED",
+            "headRefName": "issue1",
+            "author": {
+              "login": "owner"
+            }
+          }
+        }
+      ]
+    }
+  }
+}
\ No newline at end of file
diff --git a/fixtures/gh/repo_origin.json b/fixtures/gh/repo_origin.json
new file mode 100644
index 0000000..c4a7d13
--- /dev/null
+++ b/fixtures/gh/repo_origin.json
@@ -0,0 +1,9 @@
+{
+  "name": "repo",
+  "owner": {
+    "id": "1",
+    "login": "owner"
+  },
+  "parent": null,
+  "url": "https://github.com/owner/repo"
+}
\ No newline at end of file
diff --git a/fixtures/gh/repo_origin_upstream.json b/fixtures/gh/repo_origin_upstream.json
new file mode 100644
index 0000000..610176c
--- /dev/null
+++ b/fixtures/gh/repo_origin_upstream.json
@@ -0,0 +1,16 @@
+{
+  "name": "repo",
+  "owner": {
+    "id": "1",
+    "login": "owner"
+  },
+  "parent": {
+    "id": "2",
+    "name": "repo",
+    "owner": {
+      "id": "3",
+      "login": "parent-owner"
+    }
+  },
+  "url": "https://github.com/owner/repo"
+}
\ No newline at end of file
diff --git a/fixtures/git/branch_@main.txt b/fixtures/git/branch_@main.txt
new file mode 100644
index 0000000..02a86d5
--- /dev/null
+++ b/fixtures/git/branch_@main.txt
@@ -0,0 +1,1 @@
+*,main,b6589fc6ab0dc82cf12099d1c2d40ab994e8410c
diff --git a/fixtures/git/branch_@main_issue1.txt b/fixtures/git/branch_@main_issue1.txt
new file mode 100644
index 0000000..a1a96d9
--- /dev/null
+++ b/fixtures/git/branch_@main_issue1.txt
@@ -0,0 +1,2 @@
+ ,issue1,356a192b7913b04c54574d18c28d46e6395428ab
+*,main,b6589fc6ab0dc82cf12099d1c2d40ab994e8410c
diff --git a/fixtures/git/branch_main_@issue1.txt b/fixtures/git/branch_main_@issue1.txt
new file mode 100644
index 0000000..4951f65
--- /dev/null
+++ b/fixtures/git/branch_main_@issue1.txt
@@ -0,0 +1,3 @@
+*,issue1,356a192b7913b04c54574d18c28d46e6395428ab
+ ,main,b6589fc6ab0dc82cf12099d1c2d40ab994e8410c
+ 
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 7150a8a..ffdb9ad 100644
--- a/go.mod
+++ b/go.mod
@@ -4,11 +4,11 @@ go 1.17
 
 require (
 	github.com/briandowns/spinner v1.13.0
-	github.com/cli/safeexec v1.0.0
 	github.com/fatih/color v1.13.0
 	github.com/golang/mock v1.6.0
 	github.com/pkg/errors v0.9.1
 	github.com/stretchr/testify v1.7.0
+	golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c
 )
 
 require (
@@ -16,6 +16,5 @@ require (
 	github.com/mattn/go-colorable v0.1.9 // indirect
 	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
 	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
 )
diff --git a/go.sum b/go.sum
index ab7c4a2..9c4a3a8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,5 @@
 github.com/briandowns/spinner v1.13.0 h1:q/Y9LtpwtvL0CRzXrAMj0keVXqNhBYUFg6tBOUiY8ek=
 github.com/briandowns/spinner v1.13.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
-github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
-github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
 github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
@@ -40,8 +38,9 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c h1:DHcbWVXeY+0Y8HHKR+rbLwnoh2F4tNCY7rTiHJ30RmA=
+golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
diff --git a/main.go b/main.go
index 05f01cd..232a9e3 100644
--- a/main.go
+++ b/main.go
@@ -23,26 +23,23 @@ func main() {
 	flag.BoolVar(&check, "check", false, "Show branches to delete")
 	flag.Parse()
 
+	runMain(check)
+}
+
+func runMain(check bool) {
 	if check {
 		fmt.Fprintf(color.Output, "%s\n", whiteBold("== DRY RUN =="))
 	}
 
-	sp := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
-	sp.Start()
-
 	conn := &ConnectionImpl{}
+	sp := spinner.New(spinner.CharSets[14], 40*time.Millisecond)
 
 	fetchingMsg := " Fetching pull requests..."
-	var fetchingErr error
 	sp.Suffix = fetchingMsg
-	branches, fetchingErr := GetBranches(conn)
+	sp.Start()
+	var fetchingErr error
 
-	deletingMsg := " Deleting branches..."
-	var deletingErr error
-	if !check && fetchingErr == nil {
-		sp.Suffix = deletingMsg
-		branches, deletingErr = DeleteBranches(branches, conn)
-	}
+	branches, fetchingErr := GetBranches(conn)
 
 	sp.Stop()
 
@@ -54,9 +51,19 @@ func main() {
 		return
 	}
 
+	deletingMsg := " Deleting branches..."
+	var deletingErr error
+
 	if check {
 		fmt.Fprintf(color.Output, "%s%s\n", hiBlack("-"), deletingMsg)
 	} else {
+		sp.Suffix = deletingMsg
+		sp.Restart()
+
+		branches, deletingErr = DeleteBranches(branches, conn)
+
+		sp.Stop()
+
 		if deletingErr == nil {
 			fmt.Fprintf(color.Output, "%s%s\n", green("✔"), deletingMsg)
 		} else {
diff --git a/mocks/poi_mock.go b/mocks/poi_mock.go
index 4ecaf29..391bc86 100644
--- a/mocks/poi_mock.go
+++ b/mocks/poi_mock.go
@@ -48,62 +48,47 @@ func (mr *MockConnectionMockRecorder) DeleteBranches(branchNames interface{}) *g
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBranches", reflect.TypeOf((*MockConnection)(nil).DeleteBranches), branchNames)
 }
 
-// FetchPrStates mocks base method.
-func (m *MockConnection) FetchPrStates(hostname string, repoNames []string, queryHashes string) (string, error) {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "FetchPrStates", hostname, repoNames, queryHashes)
-	ret0, _ := ret[0].(string)
-	ret1, _ := ret[1].(error)
-	return ret0, ret1
-}
-
-// FetchPrStates indicates an expected call of FetchPrStates.
-func (mr *MockConnectionMockRecorder) FetchPrStates(hostname, repoNames, queryHashes interface{}) *gomock.Call {
-	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchPrStates", reflect.TypeOf((*MockConnection)(nil).FetchPrStates), hostname, repoNames, queryHashes)
-}
-
-// FetchRepoNames mocks base method.
-func (m *MockConnection) FetchRepoNames() (string, error) {
+// GetBrancheNames mocks base method.
+func (m *MockConnection) GetBrancheNames() (string, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "FetchRepoNames")
+	ret := m.ctrl.Call(m, "GetBrancheNames")
 	ret0, _ := ret[0].(string)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
 
-// FetchRepoNames indicates an expected call of FetchRepoNames.
-func (mr *MockConnectionMockRecorder) FetchRepoNames() *gomock.Call {
+// GetBrancheNames indicates an expected call of GetBrancheNames.
+func (mr *MockConnectionMockRecorder) GetBrancheNames() *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchRepoNames", reflect.TypeOf((*MockConnection)(nil).FetchRepoNames))
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBrancheNames", reflect.TypeOf((*MockConnection)(nil).GetBrancheNames))
 }
 
-// GetBrancheNames mocks base method.
-func (m *MockConnection) GetBrancheNames() (string, error) {
+// GetPullRequests mocks base method.
+func (m *MockConnection) GetPullRequests(hostname string, repoNames []string, queryHashes string) (string, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "GetBrancheNames")
+	ret := m.ctrl.Call(m, "GetPullRequests", hostname, repoNames, queryHashes)
 	ret0, _ := ret[0].(string)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
 
-// GetBrancheNames indicates an expected call of GetBrancheNames.
-func (mr *MockConnectionMockRecorder) GetBrancheNames() *gomock.Call {
+// GetPullRequests indicates an expected call of GetPullRequests.
+func (mr *MockConnectionMockRecorder) GetPullRequests(hostname, repoNames, queryHashes interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBrancheNames", reflect.TypeOf((*MockConnection)(nil).GetBrancheNames))
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullRequests", reflect.TypeOf((*MockConnection)(nil).GetPullRequests), hostname, repoNames, queryHashes)
 }
 
-// GetRemoteName mocks base method.
-func (m *MockConnection) GetRemoteName() (string, error) {
+// GetRepoNames mocks base method.
+func (m *MockConnection) GetRepoNames() (string, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "GetRemoteName")
+	ret := m.ctrl.Call(m, "GetRepoNames")
 	ret0, _ := ret[0].(string)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
 
-// GetRemoteName indicates an expected call of GetRemoteName.
-func (mr *MockConnectionMockRecorder) GetRemoteName() *gomock.Call {
+// GetRepoNames indicates an expected call of GetRepoNames.
+func (mr *MockConnectionMockRecorder) GetRepoNames() *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRemoteName", reflect.TypeOf((*MockConnection)(nil).GetRemoteName))
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRepoNames", reflect.TypeOf((*MockConnection)(nil).GetRepoNames))
 }
diff --git a/poi.go b/poi.go
index 001be64..92f2515 100644
--- a/poi.go
+++ b/poi.go
@@ -5,26 +5,30 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
-	"os/exec"
 	"regexp"
 	"strings"
 
-	"github.com/cli/safeexec"
 	"github.com/pkg/errors"
+	exec "golang.org/x/sys/execabs"
 )
 
 type (
 	Connection interface {
-		GetRemoteName() (string, error)
+		GetRepoNames() (string, error)
 		GetBrancheNames() (string, error)
+		GetPullRequests(hostname string, repoNames []string, queryHashes string) (string, error)
 		DeleteBranches(branchNames []string) (string, error)
-		FetchRepoNames() (string, error)
-		FetchPrStates(hostname string, repoNames []string, queryHashes string) (string, error)
 	}
 
 	ConnectionImpl struct {
 	}
 
+	Repo struct {
+		Hostname string
+		Origin   string
+		Upstream string
+	}
+
 	BranchState int
 
 	Branch struct {
@@ -62,16 +66,9 @@ const (
 var ErrNotFound = errors.New("not found")
 
 func GetBranches(conn Connection) ([]Branch, error) {
-	var hostname string
-	if name, err := conn.GetRemoteName(); err == nil {
-		hostname = getHostname(name)
-	} else {
-		return nil, err
-	}
-
-	var repoNames []string
-	if names, err := conn.FetchRepoNames(); err == nil {
-		repoNames = strings.Split(names, ",")
+	var hostname, origin, upstream string
+	if json, err := conn.GetRepoNames(); err == nil {
+		hostname, origin, upstream, err = getRepo(json)
 	} else {
 		return nil, err
 	}
@@ -85,12 +82,12 @@ func GetBranches(conn Connection) ([]Branch, error) {
 
 	prs := []PullRequest{}
 	for _, queryHashes := range GetQueryHashes(branches) {
-		states, err := conn.FetchPrStates(hostname, repoNames, queryHashes)
+		json, err := conn.GetPullRequests(hostname, []string{origin, upstream}, queryHashes)
 		if err != nil {
 			return nil, err
 		}
 
-		if pr, err := fromJson(states); err == nil {
+		if pr, err := toPullRequests(json); err == nil {
 			prs = append(prs, pr...)
 		}
 	}
@@ -100,12 +97,6 @@ func GetBranches(conn Connection) ([]Branch, error) {
 	return branches, nil
 }
 
-func getHostname(remoteName string) string {
-	r := regexp.MustCompile("(?:@|//)(.+?)(?::|/)")
-	found := r.FindSubmatch([]byte(remoteName))
-	return string(found[1])
-}
-
 func applyPullRequest(branches []Branch, prs []PullRequest) []Branch {
 	results := []Branch{}
 	for _, branch := range branches {
@@ -182,7 +173,39 @@ func toBranch(branchNames []string) []Branch {
 	return results
 }
 
-func fromJson(jsonResp string) ([]PullRequest, error) {
+func getRepo(jsonResp string) (string, string, string, error) {
+	type response struct {
+		Name  string
+		Owner struct {
+			Login string
+		}
+		Parent struct {
+			Name  string
+			Owner struct {
+				Login string
+			}
+		}
+		Url string
+	}
+
+	var resp response
+	if err := json.Unmarshal([]byte(jsonResp), &resp); err != nil {
+		return "", "", "", fmt.Errorf("error unmarshaling response: %w", err)
+	}
+
+	return getHostname(resp.Url),
+		resp.Owner.Login + "/" + resp.Name,
+		resp.Parent.Owner.Login + "/" + resp.Parent.Name,
+		nil
+}
+
+func getHostname(url string) string {
+	r := regexp.MustCompile("//(.+?)/")
+	found := r.FindSubmatch([]byte(url))
+	return string(found[1])
+}
+
+func toPullRequests(jsonResp string) ([]PullRequest, error) {
 	type response struct {
 		Data struct {
 			Search struct {
@@ -286,11 +309,15 @@ func branchNameExists(branchName string, branches []Branch) bool {
 	return false
 }
 
-func (conn *ConnectionImpl) GetRemoteName() (string, error) {
+func (conn *ConnectionImpl) GetRepoNames() (string, error) {
 	args := []string{
-		"remote", "-v",
+		"repo", "view",
+		"--json", "url",
+		"--json", "owner",
+		"--json", "name",
+		"--json", "parent",
 	}
-	return run("git", args)
+	return run("gh", args)
 }
 
 func (conn *ConnectionImpl) GetBrancheNames() (string, error) {
@@ -301,25 +328,7 @@ func (conn *ConnectionImpl) GetBrancheNames() (string, error) {
 	return run("git", args)
 }
 
-func (conn *ConnectionImpl) DeleteBranches(branchNames []string) (string, error) {
-	args := append([]string{
-		"branch", "-D"},
-		branchNames...)
-	return run("git", args)
-}
-
-func (conn *ConnectionImpl) FetchRepoNames() (string, error) {
-	args := []string{
-		"repo", "view",
-		"--json", "owner",
-		"--json", "name",
-		"--json", "parent",
-		"--template", "{{ .owner.login }}/{{ .name }}{{ if.parent }},{{ .parent.owner.login }}/{{ .parent.name }}{{ end }}",
-	}
-	return run("gh", args)
-}
-
-func (conn *ConnectionImpl) FetchPrStates(
+func (conn *ConnectionImpl) GetPullRequests(
 	hostname string, repoNames []string, queryHashes string) (string, error) {
 	args := []string{
 		"api", "graphql",
@@ -347,6 +356,13 @@ func (conn *ConnectionImpl) FetchPrStates(
 	return run("gh", args)
 }
 
+func (conn *ConnectionImpl) DeleteBranches(branchNames []string) (string, error) {
+	args := append([]string{
+		"branch", "-D"},
+		branchNames...)
+	return run("git", args)
+}
+
 func getQueryRepos(repoNames []string) string {
 	var repos strings.Builder
 	for _, name := range repoNames {
@@ -355,21 +371,17 @@ func getQueryRepos(repoNames []string) string {
 	return repos.String()
 }
 
-func run(file string, args []string) (string, error) {
-	bin, err := safeexec.LookPath(file)
-	if err != nil {
-		return "", fmt.Errorf("failed to run external command: %s", file)
-	}
-
+func run(name string, args []string) (string, error) {
 	var stdout, stderr bytes.Buffer
-	cmd := exec.Command(bin, args...)
+	cmd := exec.Command(name, args...)
 	cmd.Stdout = &stdout
 	cmd.Stderr = &stderr
 
-	err = cmd.Run()
+	err := cmd.Start()
 	if err != nil {
-		return "", fmt.Errorf("failed to run external command: %s", file)
+		return "", fmt.Errorf("failed to run external command: %s", name)
 	}
+	cmd.Wait()
 
 	return stdout.String(), nil
 }


Test patch:
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..1a79b8f
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"os"
+	"testing"
+
+	"github.com/fatih/color"
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_DeletingBranchesWhenTheCheckOptionIsFalse(t *testing.T) {
+	onlyCI(t)
+
+	actual := captureOutput(func() { runMain(false) })
+
+	expected := fmt.Sprintf("%s %s", green("✔"), "Deleting branches...")
+	assert.Contains(t, actual, expected)
+}
+
+func Test_DoNotDeleteBranchesWhenTheCheckOptionIsTrue(t *testing.T) {
+	onlyCI(t)
+
+	actual := captureOutput(func() { runMain(true) })
+
+	expected := fmt.Sprintf("%s %s", hiBlack("-"), "Deleting branches...")
+	assert.Contains(t, actual, expected)
+}
+
+func onlyCI(t *testing.T) {
+	if os.Getenv("CI") == "" {
+		t.Skip("skipping test in local")
+	}
+}
+
+func captureOutput(f func()) string {
+	r, w, _ := os.Pipe()
+	os.Stdout = w
+	color.Output = w
+
+	f()
+
+	w.Close()
+	var buf bytes.Buffer
+	io.Copy(&buf, r)
+
+	return buf.String()
+}
diff --git a/poi_test.go b/poi_test.go
index ae00b3e..c04e8c9 100644
--- a/poi_test.go
+++ b/poi_test.go
@@ -1,68 +1,29 @@
 package main
 
 import (
+	"errors"
 	"testing"
 
 	"github.com/golang/mock/gomock"
-	"github.com/seachicken/gh-poi/mocks"
+	"github.com/seachicken/gh-poi/connmock"
 	"github.com/stretchr/testify/assert"
 )
 
-func TestGetBranches(t *testing.T) {
+func Test_ShouldBeDeletableWhenBranchesAssociatedWithMergedPR(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
 
-	conn := mocks.NewMockConnection(ctrl)
-	conn.
-		EXPECT().
-		GetRemoteName().
-		Return(`origin  git@github.com:owner/name.git (fetch)
-origin  git@github.com:owner/name.git (push)
-`, nil).
-		AnyTimes()
-	conn.
-		EXPECT().
-		GetBrancheNames().
-		Return(` ,issue1,356a192b7913b04c54574d18c28d46e6395428ab
-*,main,b6589fc6ab0dc82cf12099d1c2d40ab994e8410c
-`, nil).
-		AnyTimes()
-	conn.
-		EXPECT().
-		FetchRepoNames().
-		Return("owner/name", nil).
-		AnyTimes()
-	conn.
-		EXPECT().
-		FetchPrStates(gomock.Any(), gomock.Any(), gomock.Any()).
-		Return(`{
-  "data": {
-    "search": {
-      "issueCount": 1,
-      "edges": [
-        {
-          "node": {
-            "number": 1,
-            "url": "https://github.com/owner/name/pull/1",
-            "state": "MERGED",
-            "headRefName": "issue1",
-            "author": {
-              "login": "owner"
-            }
-          }
-        }
-      ]
-    }
-  }
-}`, nil).
-		AnyTimes()
-
-	actual, _ := GetBranches(conn)
+	s := connmock.Setup(ctrl).
+		GetRepoNames("origin", nil, nil).
+		GetBrancheNames("@main_issue1", nil, nil).
+		GetPullRequests("issue1Merged", nil, nil)
+
+	actual, _ := GetBranches(s.Conn)
 
 	assert.Equal(t, []Branch{
 		{false, "issue1", "356a192b7913b04c54574d18c28d46e6395428ab",
 			[]PullRequest{
-				PullRequest{"issue1", Merged, 1, "https://github.com/owner/name/pull/1", "owner"},
+				{"issue1", Merged, 1, "https://github.com/owner/repo/pull/1", "owner"},
 			},
 			Deletable,
 		},
@@ -73,76 +34,75 @@ origin  git@github.com:owner/name.git (push)
 	}, actual)
 }
 
-func TestGetBranches_pullRequestsAreNotMerged(t *testing.T) {
+func Test_ShouldBeDeletableWhenBranchesAssociatedWithUpstreamMergedPR(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
 
-	conn := mocks.NewMockConnection(ctrl)
-	conn.
-		EXPECT().
-		GetRemoteName().
-		Return(`origin  git@github.com:owner/name.git (fetch)
-origin  git@github.com:owner/name.git (push)
-`, nil).
-		AnyTimes()
-	conn.
-		EXPECT().
-		GetBrancheNames().
-		Return(` ,issue1,356a192b7913b04c54574d18c28d46e6395428ab
-*,main,b6589fc6ab0dc82cf12099d1c2d40ab994e8410c
-`, nil).
-		AnyTimes()
-	conn.
-		EXPECT().
-		FetchRepoNames().
-		Return("owner/name", nil).
-		AnyTimes()
-	conn.
-		EXPECT().
-		FetchPrStates(gomock.Any(), gomock.Any(), gomock.Any()).
-		Return(`{
-  "data": {
-    "search": {
-      "issueCount": 2,
-      "edges": [
-        {
-          "node": {
-            "number": 1,
-            "url": "https://github.com/owner/name/pull/1",
-            "state": "CLOSED",
-            "headRefName": "issue1",
-            "author": {
-              "login": "owner"
-            }
-          }
-        },
-        {
-          "node": {
-            "number": 2,
-            "url": "https://github.com/owner/name/pull/2",
-            "state": "CLOSED",
-            "headRefName": "issue1",
-            "author": {
-              "login": "owner"
-            }
-          }
-        }
-      ]
-    }
-  }
-}`, nil).
-		AnyTimes()
-
-	actual, _ := GetBranches(conn)
+	s := connmock.Setup(ctrl).
+		GetRepoNames("origin_upstream", nil, nil).
+		GetBrancheNames("@main_issue1", nil, nil).
+		GetPullRequests("issue1UpMerged", nil, nil)
+
+	actual, _ := GetBranches(s.Conn)
 
 	assert.Equal(t, []Branch{
 		{false, "issue1", "356a192b7913b04c54574d18c28d46e6395428ab",
 			[]PullRequest{
-				PullRequest{"issue1", Closed, 1, "https://github.com/owner/name/pull/1", "owner"},
-				PullRequest{"issue1", Closed, 2, "https://github.com/owner/name/pull/2", "owner"},
+				{"issue1", Merged, 1, "https://github.com/parent-owner/repo/pull/1", "owner"},
 			},
+			Deletable,
+		},
+		{true, "main", "b6589fc6ab0dc82cf12099d1c2d40ab994e8410c",
+			[]PullRequest{},
 			NotDeletable,
 		},
+	}, actual)
+}
+
+func Test_ShouldNotDeletableWhenBranchesAssociatedWithClosedPR(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	s := connmock.Setup(ctrl).
+		GetRepoNames("origin", nil, nil).
+		GetBrancheNames("@main_issue1", nil, nil).
+		GetPullRequests("issue1Closed", nil, nil)
+
+	actual, _ := GetBranches(s.Conn)
+
+	assert.Equal(t, []Branch{
+		{false, "issue1", "356a192b7913b04c54574d18c28d46e6395428ab",
+			[]PullRequest{
+				{"issue1", Closed, 1, "https://github.com/owner/repo/pull/1", "owner"},
+			},
+			NotDeletable,
+		},
+		{true, "main", "b6589fc6ab0dc82cf12099d1c2d40ab994e8410c",
+			[]PullRequest{},
+			NotDeletable,
+		},
+	}, actual)
+}
+
+func Test_ShouldBeDeletableWhenBranchesAssociatedWithMergedAndClosedPRs(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	s := connmock.Setup(ctrl).
+		GetRepoNames("origin", nil, nil).
+		GetBrancheNames("@main_issue1", nil, nil).
+		GetPullRequests("issue1Merged_issue1Closed", nil, nil)
+
+	actual, _ := GetBranches(s.Conn)
+
+	assert.Equal(t, []Branch{
+		{false, "issue1", "356a192b7913b04c54574d18c28d46e6395428ab",
+			[]PullRequest{
+				{"issue1", Closed, 1, "https://github.com/owner/repo/pull/1", "owner"},
+				{"issue1", Merged, 2, "https://github.com/owner/repo/pull/2", "owner"},
+			},
+			Deletable,
+		},
 		{true, "main", "b6589fc6ab0dc82cf12099d1c2d40ab994e8410c",
 			[]PullRequest{},
 			NotDeletable,
@@ -150,62 +110,84 @@ origin  git@github.com:owner/name.git (push)
 	}, actual)
 }
 
-func TestDeleteBranches(t *testing.T) {
+func Test_ReturnsAnErrorWhenGetRepoNamesFails(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
 
-	conn := mocks.NewMockConnection(ctrl)
-	conn.
-		EXPECT().
-		GetBrancheNames().
-		Return(`*,issue1,356a192b7913b04c54574d18c28d46e6395428ab
- ,main,b6589fc6ab0dc82cf12099d1c2d40ab994e8410c
-`, nil).
-		AnyTimes()
-	conn.
-		EXPECT().
-		DeleteBranches(gomock.Any()).
-		Return("", nil).
-		Times(1)
+	s := connmock.Setup(ctrl).
+		GetRepoNames("origin", errors.New("failed to run external command: git"), nil)
+
+	_, err := GetBranches(s.Conn)
+
+	assert.NotNil(t, err)
+}
+
+func Test_ReturnsAnErrorWhenGetBrancheNamesFails(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	s := connmock.Setup(ctrl).
+		GetRepoNames("origin", nil, nil).
+		GetBrancheNames("@main_issue1", errors.New("failed to run external command: gh"), nil)
+
+	_, err := GetBranches(s.Conn)
+
+	assert.NotNil(t, err)
+}
+
+func Test_ReturnsAnErrorWhenGetPullRequestsFails(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	s := connmock.Setup(ctrl).
+		GetRepoNames("origin", nil, nil).
+		GetBrancheNames("@main_issue1", nil, nil).
+		GetPullRequests("issue1Merged", errors.New("failed to run external command: gh"), nil)
+
+	_, err := GetBranches(s.Conn)
+
+	assert.NotNil(t, err)
+}
+
+func Test_DeletingDeletableBranches(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	s := connmock.Setup(ctrl).
+		GetBrancheNames("@main", nil, nil).
+		DeleteBranches(nil, connmock.NewConf(&connmock.Times{N: 1}))
 
 	branches := []Branch{
 		{false, "issue1", "", []PullRequest{}, Deletable},
-		{false, "issue2", "", []PullRequest{}, Deletable},
 		{true, "main", "", []PullRequest{}, NotDeletable},
 	}
 
-	actual, _ := DeleteBranches(branches, conn)
+	actual, _ := DeleteBranches(branches, s.Conn)
 
 	expected := []Branch{
-		{false, "issue1", "", []PullRequest{}, Deletable},
-		{false, "issue2", "", []PullRequest{}, Deleted},
+		{false, "issue1", "", []PullRequest{}, Deleted},
 		{true, "main", "", []PullRequest{}, NotDeletable},
 	}
 	assert.Equal(t, expected, actual)
 }
 
-func TestDeleteBranches_doesNotExistsDeletableBranches(t *testing.T) {
+func Test_DoNotDeleteNotDeletableBranches(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
 
-	conn := mocks.NewMockConnection(ctrl)
-	conn.
-		EXPECT().
-		GetBrancheNames().
-		Return(` ,issue1,356a192b7913b04c54574d18c28d46e6395428ab
-*,main,b6589fc6ab0dc82cf12099d1c2d40ab994e8410c
-`, nil).
-		AnyTimes()
-	conn.
-		EXPECT().
-		DeleteBranches(gomock.Any()).
-		Return("", nil).
-		Times(0)
+	s := connmock.Setup(ctrl).
+		DeleteBranches(nil, connmock.NewConf(&connmock.Times{N: 0}))
 
 	branches := []Branch{
-		{true, "issue1", "", []PullRequest{}, NotDeletable},
-		{false, "main", "", []PullRequest{}, NotDeletable},
+		{false, "issue1", "", []PullRequest{}, NotDeletable},
+		{true, "main", "", []PullRequest{}, NotDeletable},
 	}
 
-	DeleteBranches(branches, conn)
+	actual, _ := DeleteBranches(branches, s.Conn)
+
+	expected := []Branch{
+		{false, "issue1", "", []PullRequest{}, NotDeletable},
+		{true, "main", "", []PullRequest{}, NotDeletable},
+	}
+	assert.Equal(t, expected, actual)
 }
diff --git a/querygen_test.go b/querygen_test.go
index 8c527db..a6725cb 100644
--- a/querygen_test.go
+++ b/querygen_test.go
@@ -6,7 +6,7 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestGetQueryHashes(t *testing.T) {
+func Test_GetQueryHashes(t *testing.T) {
 	assert.Equal(t,
 		[]string{
 			"hash:356a192b7913b04c54574d18c28d46e6395428ab " +
@@ -17,11 +17,11 @@ func TestGetQueryHashes(t *testing.T) {
 			"hash:c1dfd96eea8cc2b62785275bca38ac261256e278",
 		},
 		GetQueryHashes([]Branch{
-			Branch{false, "issue1", "356a192b7913b04c54574d18c28d46e6395428ab", []PullRequest{}, Unknown},
-			Branch{false, "issue2", "da4b9237bacccdf19c0760cab7aec4a8359010b0", []PullRequest{}, Unknown},
-			Branch{false, "issue3", "77de68daecd823babbb58edb1c8e14d7106e83bb", []PullRequest{}, Unknown},
-			Branch{false, "issue4", "1b6453892473a467d07372d45eb05abc2031647a", []PullRequest{}, Unknown},
-			Branch{false, "issue5", "ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4", []PullRequest{}, Unknown},
-			Branch{false, "issue6", "c1dfd96eea8cc2b62785275bca38ac261256e278", []PullRequest{}, Unknown},
+			{false, "issue1", "356a192b7913b04c54574d18c28d46e6395428ab", []PullRequest{}, Unknown},
+			{false, "issue2", "da4b9237bacccdf19c0760cab7aec4a8359010b0", []PullRequest{}, Unknown},
+			{false, "issue3", "77de68daecd823babbb58edb1c8e14d7106e83bb", []PullRequest{}, Unknown},
+			{false, "issue4", "1b6453892473a467d07372d45eb05abc2031647a", []PullRequest{}, Unknown},
+			{false, "issue5", "ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4", []PullRequest{}, Unknown},
+			{false, "issue6", "c1dfd96eea8cc2b62785275bca38ac261256e278", []PullRequest{}, Unknown},
 		}))
 }