├── .codecov.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── codecov.yml ├── release.yml └── workflows │ ├── ci-windows.yml │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── contract-test.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cmd ├── protect │ └── protect.go ├── root.go └── root_test.go ├── conn ├── command.go ├── command_test.go ├── fixtures │ ├── .gitignore │ ├── README.md │ ├── gh │ │ ├── pr_forkMainUpMerged.json │ │ ├── pr_issue1Closed.json │ │ ├── pr_issue1Merged.json │ │ ├── pr_issue1Merged_issue1Closed.json │ │ ├── pr_issue1UpMerged.json │ │ ├── pr_mainMerged.json │ │ ├── pr_notFound.json │ │ ├── repo_origin.json │ │ └── repo_origin_upstream.json │ ├── git │ │ ├── abranch_forkMain.txt │ │ ├── abranch_issue1.txt │ │ ├── abranch_issue1_originMain.txt │ │ ├── abranch_main.txt │ │ ├── abranch_main_forkMain.txt │ │ ├── abranch_main_issue1.txt │ │ ├── branchMerged_@main.txt │ │ ├── branchMerged_@main_issue1.txt │ │ ├── branchMerged_empty.txt │ │ ├── branchMerged_main.txt │ │ ├── branch_@issue1.txt │ │ ├── branch_@main.txt │ │ ├── branch_@main_forkMain.txt │ │ ├── branch_@main_issue1.txt │ │ ├── branch_main_@detached.txt │ │ ├── branch_main_@issue1.txt │ │ ├── config_empty.txt │ │ ├── config_mergeForkMain.txt │ │ ├── config_mergeIssue1.txt │ │ ├── config_mergeMain.txt │ │ ├── config_protected.txt │ │ ├── config_remote.txt │ │ ├── log_issue1.txt │ │ ├── log_issue1CommitAfterMerge.txt │ │ ├── log_issue1ManyCommits.txt │ │ ├── log_issue1Merged.txt │ │ ├── log_main.txt │ │ ├── log_main_issue1Merged.txt │ │ ├── log_main_issue1SquashAndMerged.txt │ │ ├── lsRemoteHead_issue1.txt │ │ ├── remoteHead_issue1.txt │ │ └── remote_origin.txt │ ├── repo_basic.zip │ └── ssh │ │ └── config_github.com.txt └── stub.go ├── gh-poi ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── mocks └── poi_mock.go └── shared ├── branch.go ├── connection.go ├── pull_request.go ├── querygen.go ├── querygen_test.go ├── remote.go └── remote_test.go /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 1% 6 | informational: true 7 | patch: off 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **Your Environment** 14 | - OS: 15 | - gh (Check with `gh --version`): 16 | - gh-poi (Check with `gh ext ls`): 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Description** 11 | 12 | 13 | **Additional context** 14 | 15 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | patch: 7 | default: 8 | informational: true 9 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | categories: 6 | - title: What's New 7 | labels: 8 | - enhancement 9 | - title: What's Changed 10 | labels: 11 | - "*" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci-windows.yml: -------------------------------------------------------------------------------- 1 | name: CI for Windows 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | tags: 8 | - '' 9 | 10 | jobs: 11 | 12 | build: 13 | runs-on: windows-latest 14 | 15 | steps: 16 | - name: Prepare git 17 | run: git config --global core.autocrlf false 18 | 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version-file: 'go.mod' 25 | 26 | - name: Build 27 | run: go build -v ./... 28 | 29 | - name: Checkout dummy repo 30 | uses: actions/checkout@v4 31 | with: 32 | repository: seachicken/can 33 | path: ci-test 34 | fetch-depth: 0 35 | 36 | - name: Test 37 | working-directory: ./ci-test 38 | run: | 39 | foreach ($branch in $(git branch --all | findstr /r '\<\s*remotes' | findstr /v /r 'main$')) { 40 | git branch --track $(echo $branch | Select-String -Pattern '.+/(.+$)' | %{$_.matches.groups[1].Value}) "$branch".trim() 41 | } 42 | echo ${{ secrets.GITHUB_TOKEN }} | gh auth login --with-token 43 | go test $(go list ../... | grep -v /conn) -v 44 | 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | tags: 8 | - '' 9 | 10 | jobs: 11 | 12 | build: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [ubuntu-latest, macos-latest] 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version-file: 'go.mod' 26 | 27 | - name: Build 28 | run: go build -v ./... 29 | 30 | - name: Checkout dummy repo 31 | uses: actions/checkout@v4 32 | with: 33 | repository: seachicken/can 34 | path: ci-test 35 | fetch-depth: 0 36 | 37 | - name: Test 38 | working-directory: ./ci-test 39 | run: | 40 | for branch in $(git branch --all | grep '^\s*remotes' | egrep --invert-match 'main$') 41 | do 42 | git branch --track "${branch##*/}" "$branch" 43 | done 44 | echo ${{ secrets.GITHUB_TOKEN }} | gh auth login --with-token 45 | go test $(go list ../... | grep -v /conn) -v -race -coverprofile=coverage.out -covermode=atomic 46 | 47 | - name: Upload coverage to Codecov 48 | uses: codecov/codecov-action@v4 49 | with: 50 | directory: ./ci-test/ 51 | token: ${{ secrets.CODECOV_TOKEN }} 52 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: '41 12 * * 6' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze (${{ matrix.language }}) 14 | runs-on: ubuntu-latest 15 | permissions: 16 | security-events: write 17 | packages: read 18 | actions: read 19 | contents: read 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | include: 25 | - language: go 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v3 32 | with: 33 | languages: ${{ matrix.language }} 34 | build-mode: ${{ matrix.build-mode }} 35 | 36 | - name: Perform CodeQL Analysis 37 | uses: github/codeql-action/analyze@v3 38 | with: 39 | category: "/language:${{matrix.language}}" 40 | -------------------------------------------------------------------------------- /.github/workflows/contract-test.yml: -------------------------------------------------------------------------------- 1 | name: Contract Test 2 | 3 | on: 4 | schedule: 5 | - cron: '0 15 * * *' 6 | 7 | jobs: 8 | 9 | test: 10 | strategy: 11 | fail-fast: false 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - run: sudo add-apt-repository ppa:git-core/ppa 18 | - run: sudo apt-get update 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version-file: 'go.mod' 24 | 25 | - name: Test 26 | run: | 27 | sudo apt-get install git 28 | unzip conn/fixtures/repo_basic.zip -d conn/fixtures 29 | go test -v ./conn 30 | 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | 10 | goreleaser: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version-file: 'go.mod' 22 | 23 | - name: Run GoReleaser 24 | uses: goreleaser/goreleaser-action@v6 25 | with: 26 | args: release --clean 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | dist/ 17 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - darwin 9 | - freebsd 10 | - linux 11 | - windows 12 | archives: 13 | - name_template: "{{ .Os }}-{{ .Arch }}" 14 | format: binary 15 | snapshot: 16 | name_template: "{{ .Tag }}-next" 17 | changelog: 18 | use: github-native 19 | release: 20 | draft: true 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Hi! Thanks for your interest in contributing to the gh poi ✨ 4 | 5 | Please let us know about bugs and feature requests via [GitHub issues](https://github.com/seachicken/gh-poi/issues/new/choose). 6 | If you find a security vulnerability, do NOT open an issue. Email stare-blurry0c@icloud.com instead. 7 | 8 | ## Prerequisites 9 | 10 | - Go 1.22+ 11 | 12 | ## Building the project 13 | 14 | Clone the project and `gh extension install .` (If already installed, then run `gh extension remove poi`) 15 | 16 | Run `gh poi` in any project directory. 17 | 18 | Please run: 19 | - `go test ./...` 20 | - `go vet ./...` 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Seito Tanaka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo_readme](https://user-images.githubusercontent.com/5178598/152155497-c06799b7-a95a-44e5-a8a0-a0a9c96ce646.png) 2 | 3 | [![CI](https://github.com/seachicken/gh-poi/actions/workflows/ci.yml/badge.svg)](https://github.com/seachicken/gh-poi/actions/workflows/ci.yml) 4 | [![codecov](https://codecov.io/gh/seachicken/gh-poi/branch/main/graph/badge.svg?token=tcPxPgst2q)](https://codecov.io/gh/seachicken/gh-poi) 5 | [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/6380/badge)](https://bestpractices.coreinfrastructure.org/projects/6380) 6 | 7 | This [gh](https://github.com/cli/cli) extension determines which local branches have been merged and safely deletes them. 8 | 9 | ![demo](https://user-images.githubusercontent.com/5178598/140624593-bf38ded3-388b-4a4b-a5c0-4053f8de51ad.gif) 10 | 11 | ## Motivation 12 | 13 | Daily development makes it difficult to know which branch is active when there are many unnecessary branches left locally, which causes a small amount of stress. If you squash merge a pull request, there is no history of the merge to the default branch, so you have to force delete the branch to clean it up, and you have to be careful not to accidentally delete the active branch. 14 | 15 | We have made it possible to automatically determine which branches have been merged and clean up the local environment without worry. 16 | 17 | ## Installation 18 | 19 | ``` 20 | gh extension install seachicken/gh-poi 21 | ``` 22 | 23 | ## Usage 24 | 25 | - `gh poi` Delete the merged local branches 26 | - `gh poi --state (closed|merged)` Specify the PR state to delete (default merged) 27 | - `gh poi --dry-run` Show branches to delete without actually deleting it 28 | - `gh poi --debug` Enable debug logs 29 | - `gh poi protect ...` Protect local branches from deletion 30 | - `gh poi unprotect ...` Unprotect local branches 31 | 32 | ## FAQ 33 | 34 | ### Why the name "poi"? 35 | 36 | "poi" means "feel free to throw it away" in Japanese. 37 | If you prefer an alias, you can change it with [gh alias set](https://cli.github.com/manual/gh_alias_set). (e.g. `gh alias set clean-branches poi`) 38 | -------------------------------------------------------------------------------- /cmd/protect/protect.go: -------------------------------------------------------------------------------- 1 | package protect 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/seachicken/gh-poi/cmd" 8 | "github.com/seachicken/gh-poi/shared" 9 | ) 10 | 11 | func ProtectBranches(ctx context.Context, targetBranchNames []string, connection shared.Connection) error { 12 | branchNameResults, err := connection.GetBranchNames(ctx) 13 | if err != nil { 14 | return err 15 | } 16 | branches := cmd.ToBranch(cmd.SplitLines(branchNameResults)) 17 | 18 | for _, targetName := range targetBranchNames { 19 | if cmd.BranchNameExists(targetName, branches) { 20 | connection.RemoveConfig(ctx, fmt.Sprintf("branch.%s.gh-poi-protected", targetName)) 21 | _, err = connection.AddConfig(ctx, fmt.Sprintf("branch.%s.gh-poi-protected", targetName), "true") 22 | if err != nil { 23 | return err 24 | } 25 | } 26 | } 27 | 28 | return nil 29 | } 30 | 31 | func UnprotectBranches(ctx context.Context, targetBranchNames []string, connection shared.Connection) error { 32 | branchNameResults, err := connection.GetBranchNames(ctx) 33 | if err != nil { 34 | return err 35 | } 36 | branches := cmd.ToBranch(cmd.SplitLines(branchNameResults)) 37 | 38 | for _, targetName := range targetBranchNames { 39 | if cmd.BranchNameExists(targetName, branches) { 40 | connection.RemoveConfig(ctx, fmt.Sprintf("branch.%s.gh-poi-protected", targetName)) 41 | } 42 | } 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "regexp" 9 | "slices" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | 15 | "github.com/pkg/errors" 16 | "github.com/seachicken/gh-poi/shared" 17 | ) 18 | 19 | type ( 20 | UncommittedChange struct { 21 | X string 22 | Y string 23 | Path string 24 | } 25 | ) 26 | 27 | const ( 28 | github = "github.com" 29 | localhost = "github.localhost" 30 | ) 31 | 32 | var ErrNotFound = errors.New("not found") 33 | 34 | func GetRemote(ctx context.Context, connection shared.Connection) (shared.Remote, error) { 35 | remoteNames, err := connection.GetRemoteNames(ctx) 36 | if err != nil { 37 | return shared.Remote{}, err 38 | } 39 | 40 | remotes := toRemotes(SplitLines(remoteNames)) 41 | if remote, err := getPrimaryRemote(remotes); err == nil { 42 | hostname := remote.Hostname 43 | ghHost := os.Getenv("GH_HOST") 44 | if ghHost == "" { 45 | if config, err := connection.GetSshConfig(ctx, hostname); err == nil { 46 | remote.Hostname = normalizeHostname(findHostname(SplitLines(config), hostname)) 47 | } 48 | } else { 49 | remote.Hostname = ghHost 50 | } 51 | return remote, nil 52 | } else { 53 | return shared.Remote{}, err 54 | } 55 | } 56 | 57 | func GetBranches(ctx context.Context, remote shared.Remote, connection shared.Connection, state shared.PullRequestState, dryRun bool) ([]shared. 58 | Branch, error) { 59 | var repoNames []string 60 | var defaultBranchName string 61 | if json, err := connection.GetRepoNames(ctx, remote.Hostname, remote.RepoName); err == nil { 62 | repoNames, defaultBranchName, err = getRepo(json) 63 | if err != nil { 64 | return nil, err 65 | } 66 | } else { 67 | return nil, err 68 | } 69 | 70 | err := connection.CheckRepos(ctx, remote.Hostname, repoNames) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | branches, err := loadBranches(ctx, remote, defaultBranchName, repoNames, connection) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | branches = checkDeletion(branches, state) 81 | 82 | branches, err = switchToDefaultBranchIfDeleted(ctx, branches, defaultBranchName, connection, dryRun) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | sort.Slice(branches, func(i, j int) bool { return branches[i].Name < branches[j].Name }) 88 | 89 | return branches, nil 90 | } 91 | 92 | func loadBranches(ctx context.Context, remote shared.Remote, defaultBranchName string, repoNames []string, connection shared.Connection) ([]shared.Branch, error) { 93 | var branches []shared.Branch 94 | 95 | if names, err := connection.GetBranchNames(ctx); err == nil { 96 | branches = ToBranch(SplitLines(names)) 97 | branches = applyDefault(branches, defaultBranchName) 98 | mergedNames, err := connection.GetMergedBranchNames(ctx, remote.Name, defaultBranchName) 99 | if err != nil { 100 | return nil, err 101 | } 102 | branches = applyMerged(branches, extractMergedBranchNames(SplitLines(mergedNames))) 103 | branches, err = applyProtected(ctx, branches, connection) 104 | if err != nil { 105 | return nil, err 106 | } 107 | branches, err = applyCommits(ctx, remote, branches, defaultBranchName, connection) 108 | if err != nil { 109 | return nil, err 110 | } 111 | branches, err = applyTrackedChanges(ctx, branches, connection) 112 | if err != nil { 113 | return nil, err 114 | } 115 | } else { 116 | return nil, err 117 | } 118 | 119 | prs := []shared.PullRequest{} 120 | orgs := shared.GetQueryOrgs(repoNames) 121 | repos := shared.GetQueryRepos(repoNames) 122 | 123 | type pullRequestResult struct { 124 | prs []shared.PullRequest 125 | err error 126 | } 127 | 128 | queryHashes := shared.GetQueryHashes(branches) 129 | prChan := make(chan pullRequestResult, len(queryHashes)) 130 | var wg sync.WaitGroup 131 | 132 | for _, queryHash := range queryHashes { 133 | wg.Add(1) 134 | go func(hash string) { 135 | defer wg.Done() 136 | json, err := connection.GetPullRequests(ctx, remote.Hostname, orgs, repos, hash) 137 | if err != nil { 138 | prChan <- pullRequestResult{err: err} 139 | return 140 | } 141 | 142 | pr, err := toPullRequests(json) 143 | if err != nil { 144 | prChan <- pullRequestResult{err: err} 145 | return 146 | } 147 | 148 | prChan <- pullRequestResult{prs: pr} 149 | }(queryHash) 150 | } 151 | 152 | go func() { 153 | wg.Wait() 154 | close(prChan) 155 | }() 156 | 157 | for result := range prChan { 158 | if result.err != nil { 159 | return nil, result.err 160 | } 161 | prs = append(prs, result.prs...) 162 | } 163 | 164 | branches = applyPullRequest(ctx, branches, prs, connection) 165 | 166 | return branches, nil 167 | } 168 | 169 | // https://github.com/cli/cli/blob/8f28d1f9d5b112b222f96eb793682ff0b5a7927d/internal/ghinstance/host.go#L26 170 | func normalizeHostname(host string) string { 171 | hostname := strings.ToLower(host) 172 | if strings.HasSuffix(hostname, "."+github) { 173 | return github 174 | } 175 | if strings.HasSuffix(hostname, "."+localhost) { 176 | return localhost 177 | } 178 | return hostname 179 | } 180 | 181 | func toRemotes(remoteConfigs []string) []shared.Remote { 182 | results := []shared.Remote{} 183 | for _, remoteConfig := range remoteConfigs { 184 | results = append(results, shared.NewRemote(remoteConfig)) 185 | } 186 | return results 187 | } 188 | 189 | func getPrimaryRemote(remotes []shared.Remote) (shared.Remote, error) { 190 | if len(remotes) == 0 { 191 | return shared.Remote{}, ErrNotFound 192 | } 193 | 194 | for _, remote := range remotes { 195 | if remote.Name == "origin" { 196 | return remote, nil 197 | } 198 | } 199 | return remotes[0], nil 200 | } 201 | 202 | func findHostname(params []string, defaultName string) string { 203 | for _, param := range params { 204 | kv := strings.Split(param, " ") 205 | if kv[0] == "hostname" { 206 | return kv[1] 207 | } 208 | } 209 | return defaultName 210 | } 211 | 212 | func extractMergedBranchNames(mergedNames []string) []string { 213 | result := []string{} 214 | r := regexp.MustCompile(`^[ *]+(.+)`) 215 | for _, name := range mergedNames { 216 | found := r.FindStringSubmatch(name) 217 | if len(found) > 1 { 218 | result = append(result, found[1]) 219 | } 220 | } 221 | return result 222 | } 223 | 224 | func applyDefault(branches []shared.Branch, defaultBranchName string) []shared.Branch { 225 | results := []shared.Branch{} 226 | for _, branch := range branches { 227 | if branch.Name == defaultBranchName { 228 | branch.IsDefault = true 229 | } 230 | results = append(results, branch) 231 | } 232 | return results 233 | } 234 | 235 | func applyMerged(branches []shared.Branch, mergedNames []string) []shared.Branch { 236 | results := []shared.Branch{} 237 | for _, branch := range branches { 238 | branch.IsMerged = slices.Contains(mergedNames, branch.Name) 239 | results = append(results, branch) 240 | } 241 | return results 242 | } 243 | 244 | func applyProtected(ctx context.Context, branches []shared.Branch, connection shared.Connection) ([]shared.Branch, error) { 245 | results := []shared.Branch{} 246 | 247 | for _, branch := range branches { 248 | config, _ := connection.GetConfig(ctx, fmt.Sprintf("branch.%s.gh-poi-protected", branch.Name)) 249 | splitConfig := SplitLines(config) 250 | if len(splitConfig) > 0 && splitConfig[0] == "true" { 251 | branch.IsProtected = true 252 | } 253 | results = append(results, branch) 254 | } 255 | 256 | return results, nil 257 | } 258 | 259 | func applyCommits(ctx context.Context, remote shared.Remote, branches []shared.Branch, defaultBranchName string, connection shared.Connection) ([]shared.Branch, error) { 260 | var wg sync.WaitGroup 261 | 262 | type remoteBranchResult struct { 263 | branch shared.Branch 264 | err error 265 | } 266 | 267 | results := []shared.Branch{} 268 | resultChan := make(chan remoteBranchResult, len(branches)) 269 | 270 | for _, branch := range branches { 271 | wg.Add(1) 272 | go func(branch shared.Branch) { 273 | defer wg.Done() 274 | 275 | if branch.Name == defaultBranchName || branch.IsDetached() { 276 | branch.Commits = []string{} 277 | resultChan <- remoteBranchResult{branch: branch} 278 | return 279 | } 280 | 281 | if remoteHeadOid, err := connection.GetRemoteHeadOid(ctx, remote.Name, branch.Name); err == nil { 282 | branch.RemoteHeadOid = SplitLines(remoteHeadOid)[0] 283 | } else { 284 | result, _ := connection.GetConfig(ctx, fmt.Sprintf("branch.%s.remote", branch.Name)) 285 | splitResults := SplitLines(result) 286 | if len(splitResults) > 0 { 287 | remoteUrl := splitResults[0] 288 | if result, err := connection.GetLsRemoteHeadOid(ctx, remoteUrl, branch.Name); err == nil { 289 | splitResults := strings.Fields(result) 290 | if len(splitResults) > 0 { 291 | branch.RemoteHeadOid = splitResults[0] 292 | } 293 | } 294 | } 295 | } 296 | 297 | oids, err := connection.GetLog(ctx, branch.Name) 298 | if err != nil { 299 | resultChan <- remoteBranchResult{err: err} 300 | return 301 | } 302 | 303 | trimmedOids, err := trimBranch( 304 | ctx, SplitLines(oids), branch.RemoteHeadOid, branch.IsMerged, 305 | branch.Name, defaultBranchName, connection) 306 | if err != nil { 307 | resultChan <- remoteBranchResult{err: err} 308 | return 309 | } 310 | 311 | branch.Commits = trimmedOids 312 | resultChan <- remoteBranchResult{branch: branch} 313 | }(branch) 314 | } 315 | 316 | go func() { 317 | wg.Wait() 318 | close(resultChan) 319 | }() 320 | 321 | for result := range resultChan { 322 | if result.err != nil { 323 | return nil, result.err 324 | } 325 | results = append(results, result.branch) 326 | } 327 | 328 | return results, nil 329 | } 330 | 331 | func applyTrackedChanges(ctx context.Context, branches []shared.Branch, connection shared.Connection) ([]shared.Branch, error) { 332 | var uncommittedChanges []UncommittedChange 333 | if changes, err := connection.GetUncommittedChanges(ctx); err == nil { 334 | uncommittedChanges = toUncommittedChange(SplitLines(changes)) 335 | } else { 336 | return nil, err 337 | } 338 | 339 | results := []shared.Branch{} 340 | for _, branch := range branches { 341 | if branch.Head { 342 | hasTrackedChanges := false 343 | for _, change := range uncommittedChanges { 344 | if !change.IsUntracked() { 345 | hasTrackedChanges = true 346 | break 347 | } 348 | } 349 | if hasTrackedChanges { 350 | branch.HasTrackedChanges = true 351 | } 352 | } 353 | results = append(results, branch) 354 | } 355 | return results, nil 356 | } 357 | 358 | func trimBranch(ctx context.Context, oids []string, remoteHeadOid string, isMerged bool, 359 | branchName string, defaultBranchName string, connection shared.Connection) ([]string, error) { 360 | results := []string{} 361 | childNames := []string{} 362 | 363 | for i, oid := range oids { 364 | if len(remoteHeadOid) > 0 || isMerged { 365 | results = append(results, oid) 366 | break 367 | } 368 | 369 | refNames, err := connection.GetAssociatedRefNames(ctx, oid) 370 | if err != nil { 371 | return nil, err 372 | } 373 | names := extractBranchNames(SplitLines(refNames)) 374 | 375 | if i == 0 { 376 | for _, name := range names { 377 | if name == defaultBranchName { 378 | return []string{}, nil 379 | } 380 | if name != branchName { 381 | childNames = append(childNames, name) 382 | } 383 | } 384 | } 385 | 386 | isChild := func(name string) bool { 387 | for _, childName := range childNames { 388 | if name == childName { 389 | return true 390 | } 391 | } 392 | return false 393 | } 394 | 395 | for _, name := range names { 396 | if name != branchName && !isChild(name) { 397 | return results, nil 398 | } 399 | } 400 | 401 | results = append(results, oid) 402 | } 403 | 404 | return results, nil 405 | } 406 | 407 | func extractBranchNames(refNames []string) []string { 408 | result := []string{} 409 | r := regexp.MustCompile(`^refs/(?:heads|remotes/.+?)/`) 410 | for _, name := range refNames { 411 | result = append(result, r.ReplaceAllString(name, "")) 412 | } 413 | return result 414 | } 415 | 416 | func applyPullRequest(ctx context.Context, branches []shared.Branch, prs []shared.PullRequest, connection shared.Connection) []shared.Branch { 417 | prNumbers := map[string]int{} 418 | for _, branch := range branches { 419 | if branch.IsDetached() { 420 | continue 421 | } 422 | mergeConfig, _ := connection.GetConfig(ctx, fmt.Sprintf("branch.%s.merge", branch.Name)) 423 | if n := getPRNumber(mergeConfig); n > 0 { 424 | prNumbers[branch.Name] = n 425 | } 426 | } 427 | 428 | results := []shared.Branch{} 429 | for _, branch := range branches { 430 | prs := findMatchedPullRequest(branch.Name, prs, prNumbers) 431 | sort.Slice(prs, func(i, j int) bool { return prs[i].Number < prs[j].Number }) 432 | branch.PullRequests = prs 433 | results = append(results, branch) 434 | } 435 | return results 436 | } 437 | 438 | func getPRNumber(mergeConfig string) int { 439 | r := regexp.MustCompile(`^refs/pull/(\d+)`) 440 | found := r.FindStringSubmatch(mergeConfig) 441 | if len(found) > 0 { 442 | num, err := strconv.Atoi(found[1]) 443 | if err != nil { 444 | return 0 445 | } 446 | return num 447 | } else { 448 | return 0 449 | } 450 | } 451 | 452 | func findMatchedPullRequest(branchName string, prs []shared.PullRequest, prNumbers map[string]int) []shared.PullRequest { 453 | results := []shared.PullRequest{} 454 | 455 | prExists := func(pr shared.PullRequest) bool { 456 | for _, result := range results { 457 | if pr.Number == result.Number { 458 | return true 459 | } 460 | } 461 | return false 462 | } 463 | 464 | prNumberExists := func(prNumber int) bool { 465 | for _, n := range prNumbers { 466 | if n == prNumber { 467 | return true 468 | } 469 | } 470 | return false 471 | } 472 | 473 | for _, pr := range prs { 474 | if prExists(pr) { 475 | continue 476 | } 477 | 478 | if prNumberExists(pr.Number) { 479 | if pr.Number == prNumbers[branchName] { 480 | results = append(results, pr) 481 | } 482 | } else if pr.Name == branchName { 483 | results = append(results, pr) 484 | } 485 | } 486 | 487 | return results 488 | } 489 | 490 | func toUncommittedChange(changes []string) []UncommittedChange { 491 | results := []UncommittedChange{} 492 | for _, change := range changes { 493 | results = append(results, UncommittedChange{ 494 | string(change[0]), 495 | string(change[1]), 496 | string(change[3:]), 497 | }) 498 | } 499 | return results 500 | } 501 | 502 | func checkDeletion(branches []shared.Branch, state shared.PullRequestState) []shared.Branch { 503 | results := []shared.Branch{} 504 | for _, branch := range branches { 505 | branch.State = getDeleteStatus(branch, state) 506 | results = append(results, branch) 507 | } 508 | return results 509 | } 510 | 511 | func getDeleteStatus(branch shared.Branch, state shared.PullRequestState) shared.BranchState { 512 | if branch.IsProtected { 513 | return shared.NotDeletable 514 | } 515 | 516 | if branch.HasTrackedChanges { 517 | return shared.NotDeletable 518 | } 519 | 520 | if len(branch.PullRequests) == 0 { 521 | return shared.NotDeletable 522 | } 523 | 524 | fullyMergedCnt := 0 525 | for _, pr := range branch.PullRequests { 526 | if pr.State == shared.Open { 527 | return shared.NotDeletable 528 | } 529 | if isFullyMerged(branch, pr, state) { 530 | fullyMergedCnt++ 531 | } 532 | } 533 | if fullyMergedCnt == 0 { 534 | return shared.NotDeletable 535 | } 536 | 537 | return shared.Deletable 538 | } 539 | 540 | func isFullyMerged(branch shared.Branch, pr shared.PullRequest, state shared.PullRequestState) bool { 541 | if len(branch.Commits) == 0 { 542 | return false 543 | } 544 | if (state == shared.Merged && pr.State != shared.Merged) || 545 | // In the GitHub interface, closed status includes merged status, so we make it behave the same way. 546 | // https://github.com/cli/cli/issues/8102 547 | (state == shared.Closed && pr.State != shared.Closed && pr.State != shared.Merged) { 548 | return false 549 | } 550 | 551 | localHeadOid := branch.Commits[0] 552 | for _, oid := range pr.Commits { 553 | if oid == localHeadOid { 554 | return true 555 | } 556 | } 557 | 558 | return false 559 | } 560 | 561 | func switchToDefaultBranchIfDeleted(ctx context.Context, branches []shared.Branch, defaultBranchName string, connection shared.Connection, dryRun bool) ([]shared.Branch, error) { 562 | needsCheckout := false 563 | for _, branch := range branches { 564 | if branch.Head && branch.State == shared.Deletable { 565 | needsCheckout = true 566 | break 567 | } 568 | } 569 | 570 | if !needsCheckout { 571 | return branches, nil 572 | } 573 | 574 | results := []shared.Branch{} 575 | 576 | if !dryRun { 577 | _, err := connection.CheckoutBranch(ctx, defaultBranchName) 578 | if err != nil { 579 | return nil, err 580 | } 581 | } 582 | 583 | if !BranchNameExists(defaultBranchName, branches) { 584 | branch := shared.Branch{} 585 | branch.Head = true 586 | branch.Name = defaultBranchName 587 | branch.State = shared.NotDeletable 588 | results = append(results, branch) 589 | } 590 | 591 | for _, branch := range branches { 592 | if branch.Name == defaultBranchName { 593 | branch.Head = true 594 | } else { 595 | branch.Head = false 596 | } 597 | results = append(results, branch) 598 | } 599 | 600 | return results, nil 601 | } 602 | 603 | func ToBranch(branchNames []string) []shared.Branch { 604 | results := []shared.Branch{} 605 | 606 | for _, branchName := range branchNames { 607 | branch := shared.Branch{} 608 | splitNames := strings.Split(branchName, ":") 609 | branch.Head = splitNames[0] == "*" 610 | branch.Name = splitNames[1] 611 | results = append(results, branch) 612 | } 613 | 614 | return results 615 | } 616 | 617 | func getRepo(jsonResp string) ([]string, string, error) { 618 | type response struct { 619 | DefaultBranchRef struct { 620 | Name string 621 | } 622 | Name string 623 | Owner struct { 624 | Login string 625 | } 626 | Parent struct { 627 | Name string 628 | Owner struct { 629 | Login string 630 | } 631 | DefaultBranchName string 632 | } 633 | } 634 | 635 | var resp response 636 | if err := json.Unmarshal([]byte(jsonResp), &resp); err != nil { 637 | return nil, "", fmt.Errorf("error unmarshaling response: %w", err) 638 | } 639 | 640 | repoNames := []string{ 641 | resp.Owner.Login + "/" + resp.Name, 642 | } 643 | if len(resp.Parent.Name) > 0 { 644 | repoNames = append(repoNames, resp.Parent.Owner.Login+"/"+resp.Parent.Name) 645 | } 646 | 647 | return repoNames, resp.DefaultBranchRef.Name, nil 648 | } 649 | 650 | func toPullRequests(jsonResp string) ([]shared.PullRequest, error) { 651 | type response struct { 652 | Data struct { 653 | Search struct { 654 | IssueCount int 655 | Edges []struct { 656 | Node struct { 657 | Number int 658 | HeadRefName string 659 | HeadRefOid string 660 | Url string 661 | State string 662 | IsDraft bool 663 | Commits struct { 664 | Nodes []struct { 665 | Commit struct { 666 | Oid string 667 | } 668 | } 669 | } 670 | Author struct { 671 | Login string 672 | } 673 | } 674 | } 675 | } 676 | } 677 | } 678 | 679 | var resp response 680 | if err := json.Unmarshal([]byte(jsonResp), &resp); err != nil { 681 | return nil, fmt.Errorf("error unmarshaling response: %w", err) 682 | } 683 | 684 | results := []shared.PullRequest{} 685 | for _, edge := range resp.Data.Search.Edges { 686 | state, err := toPullRequestState(edge.Node.State) 687 | if err == ErrNotFound { 688 | return nil, fmt.Errorf("unexpected pull request state: %s", edge.Node.State) 689 | } 690 | 691 | commits := []string{} 692 | for _, node := range edge.Node.Commits.Nodes { 693 | commits = append(commits, node.Commit.Oid) 694 | } 695 | 696 | results = append(results, shared.PullRequest{ 697 | Name: edge.Node.HeadRefName, 698 | State: state, 699 | IsDraft: edge.Node.IsDraft, 700 | Number: edge.Node.Number, 701 | Commits: commits, 702 | Url: edge.Node.Url, 703 | Author: edge.Node.Author.Login, 704 | }) 705 | } 706 | 707 | return results, nil 708 | } 709 | 710 | func toPullRequestState(state string) (shared.PullRequestState, error) { 711 | switch state { 712 | case "CLOSED": 713 | return shared.Closed, nil 714 | case "MERGED": 715 | return shared.Merged, nil 716 | case "OPEN": 717 | return shared.Open, nil 718 | default: 719 | return 0, ErrNotFound 720 | } 721 | } 722 | 723 | func DeleteBranches(ctx context.Context, branches []shared.Branch, connection shared.Connection) ([]shared.Branch, error) { 724 | branchNames := getBranchNames(branches, shared.Deletable) 725 | if len(branchNames) == 0 { 726 | return branches, nil 727 | } 728 | 729 | connection.DeleteBranches(ctx, branchNames) 730 | 731 | branchNamesAfter, err := connection.GetBranchNames(ctx) 732 | if err != nil { 733 | return nil, err 734 | } 735 | branchesAfter := ToBranch(SplitLines(branchNamesAfter)) 736 | 737 | return checkDeleted(branches, branchesAfter), nil 738 | } 739 | 740 | func getBranchNames(branches []shared.Branch, state shared.BranchState) []string { 741 | results := []string{} 742 | for _, branch := range branches { 743 | if branch.State == state { 744 | results = append(results, branch.Name) 745 | } 746 | } 747 | return results 748 | } 749 | 750 | func checkDeleted(branchesBefore []shared.Branch, branchesAfter []shared.Branch) []shared.Branch { 751 | results := []shared.Branch{} 752 | for _, branch := range branchesBefore { 753 | if branch.State == shared.Deletable { 754 | if !BranchNameExists(branch.Name, branchesAfter) { 755 | branch.State = shared.Deleted 756 | } 757 | } 758 | results = append(results, branch) 759 | } 760 | return results 761 | } 762 | 763 | func BranchNameExists(branchName string, branches []shared.Branch) bool { 764 | for _, branch := range branches { 765 | if branch.Name == branchName { 766 | return true 767 | } 768 | } 769 | return false 770 | } 771 | 772 | func SplitLines(text string) []string { 773 | return strings.FieldsFunc(strings.Replace(text, "\r\n", "\n", -1), 774 | func(c rune) bool { return c == '\n' }) 775 | } 776 | 777 | func (uc *UncommittedChange) IsUntracked() bool { 778 | return uc.Y == "?" 779 | } 780 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/golang/mock/gomock" 9 | "github.com/seachicken/gh-poi/conn" 10 | "github.com/seachicken/gh-poi/shared" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var ErrCommand = errors.New("failed to run external command") 15 | 16 | func Test_DeletableWhenRemoteBranchesAssociatedWithMergedPR(t *testing.T) { 17 | ctrl := gomock.NewController(t) 18 | defer ctrl.Finish() 19 | 20 | s := conn.Setup(ctrl). 21 | CheckRepos(nil, nil). 22 | GetRemoteNames("origin", nil, nil). 23 | GetSshConfig("github.com", nil, nil). 24 | GetRepoNames("origin", nil, nil). 25 | GetBranchNames("@main_issue1", nil, nil). 26 | GetMergedBranchNames("@main_issue1", nil, nil). 27 | GetRemoteHeadOid([]conn.RemoteHeadStub{ 28 | {BranchName: "issue1", Filename: "issue1"}, 29 | }, nil, nil). 30 | GetLog([]conn.LogStub{ 31 | {BranchName: "main", Filename: "main_issue1Merged"}, {BranchName: "issue1", Filename: "issue1Merged"}, 32 | }, nil, nil). 33 | GetPullRequests("issue1Merged", nil, nil). 34 | GetUncommittedChanges("", nil, nil). 35 | GetConfig([]conn.ConfigStub{ 36 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 37 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 38 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 39 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 40 | }, nil, nil) 41 | remote, _ := GetRemote(context.Background(), s.Conn) 42 | 43 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 44 | 45 | assert.Equal(t, 2, len(actual)) 46 | assert.Equal(t, "issue1", actual[0].Name) 47 | assert.Equal(t, shared.Deletable, actual[0].State) 48 | assert.Equal(t, "main", actual[1].Name) 49 | assert.Equal(t, shared.NotDeletable, actual[1].State) 50 | } 51 | 52 | func Test_DeletableWhenLsRemoteBranchesAssociatedWithMergedPR(t *testing.T) { 53 | ctrl := gomock.NewController(t) 54 | defer ctrl.Finish() 55 | 56 | s := conn.Setup(ctrl). 57 | CheckRepos(nil, nil). 58 | GetRemoteNames("origin", nil, nil). 59 | GetSshConfig("github.com", nil, nil). 60 | GetRepoNames("origin", nil, nil). 61 | GetBranchNames("@main_issue1", nil, nil). 62 | GetMergedBranchNames("@main_issue1", nil, nil). 63 | GetRemoteHeadOid(nil, ErrCommand, nil). 64 | GetLsRemoteHeadOid([]conn.LsRemoteHeadStub{ 65 | {BranchName: "issue1", Filename: "issue1"}, 66 | }, nil, nil). 67 | GetLog([]conn.LogStub{ 68 | {BranchName: "main", Filename: "main_issue1Merged"}, {BranchName: "issue1", Filename: "issue1Merged"}, 69 | }, nil, nil). 70 | GetPullRequests("issue1Merged", nil, nil). 71 | GetUncommittedChanges("", nil, nil). 72 | GetConfig([]conn.ConfigStub{ 73 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 74 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 75 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 76 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 77 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 78 | }, nil, nil) 79 | remote, _ := GetRemote(context.Background(), s.Conn) 80 | 81 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 82 | 83 | assert.Equal(t, 2, len(actual)) 84 | assert.Equal(t, "issue1", actual[0].Name) 85 | assert.Equal(t, shared.Deletable, actual[0].State) 86 | assert.Equal(t, "main", actual[1].Name) 87 | assert.Equal(t, shared.NotDeletable, actual[1].State) 88 | } 89 | 90 | func Test_DeletableWhenBranchesAssociatedWithMergedPR(t *testing.T) { 91 | ctrl := gomock.NewController(t) 92 | defer ctrl.Finish() 93 | 94 | s := conn.Setup(ctrl). 95 | CheckRepos(nil, nil). 96 | GetRemoteNames("origin", nil, nil). 97 | GetSshConfig("github.com", nil, nil). 98 | GetRepoNames("origin", nil, nil). 99 | GetBranchNames("@main_issue1", nil, nil). 100 | GetMergedBranchNames("@main_issue1", nil, nil). 101 | GetRemoteHeadOid(nil, ErrCommand, nil). 102 | GetLsRemoteHeadOid(nil, nil, nil). 103 | GetLog([]conn.LogStub{ 104 | {BranchName: "main", Filename: "main_issue1Merged"}, {BranchName: "issue1", Filename: "issue1Merged"}, 105 | }, nil, nil). 106 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 107 | {Oid: "b8a2645298053fb62ea03e27feea6c483d3fd27e", Filename: "main_issue1"}, 108 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "main_issue1"}, 109 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 110 | }, nil, nil). 111 | GetPullRequests("issue1Merged", nil, nil). 112 | GetUncommittedChanges("", nil, nil). 113 | GetConfig([]conn.ConfigStub{ 114 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 115 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 116 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 117 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 118 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 119 | }, nil, nil) 120 | remote, _ := GetRemote(context.Background(), s.Conn) 121 | 122 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 123 | 124 | assert.Equal(t, 2, len(actual)) 125 | assert.Equal(t, "issue1", actual[0].Name) 126 | assert.Equal(t, shared.Deletable, actual[0].State) 127 | assert.Equal(t, "main", actual[1].Name) 128 | assert.Equal(t, shared.NotDeletable, actual[1].State) 129 | } 130 | 131 | func Test_DeletableWhenBranchesAssociatedWithSquashAndMergedPR(t *testing.T) { 132 | ctrl := gomock.NewController(t) 133 | defer ctrl.Finish() 134 | 135 | s := conn.Setup(ctrl). 136 | CheckRepos(nil, nil). 137 | GetRemoteNames("origin", nil, nil). 138 | GetSshConfig("github.com", nil, nil). 139 | GetRepoNames("origin", nil, nil). 140 | GetBranchNames("@main_issue1", nil, nil). 141 | GetMergedBranchNames("@main", nil, nil). 142 | GetRemoteHeadOid(nil, ErrCommand, nil). 143 | GetLsRemoteHeadOid(nil, nil, nil). 144 | GetLog([]conn.LogStub{ 145 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 146 | }, nil, nil). 147 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 148 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 149 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 150 | }, nil, nil). 151 | GetPullRequests("issue1Merged", nil, nil). 152 | GetUncommittedChanges("", nil, nil). 153 | GetConfig([]conn.ConfigStub{ 154 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 155 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 156 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 157 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 158 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 159 | }, nil, nil) 160 | remote, _ := GetRemote(context.Background(), s.Conn) 161 | 162 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 163 | 164 | assert.Equal(t, 2, len(actual)) 165 | assert.Equal(t, "issue1", actual[0].Name) 166 | assert.Equal(t, shared.Deletable, actual[0].State) 167 | assert.Equal(t, "main", actual[1].Name) 168 | assert.Equal(t, shared.NotDeletable, actual[1].State) 169 | } 170 | 171 | func Test_DeletableWhenBranchesAssociatedWithUpstreamSquashAndMergedPR(t *testing.T) { 172 | ctrl := gomock.NewController(t) 173 | defer ctrl.Finish() 174 | 175 | s := conn.Setup(ctrl). 176 | CheckRepos(nil, nil). 177 | GetRemoteNames("origin", nil, nil). 178 | GetSshConfig("github.com", nil, nil). 179 | GetRepoNames("origin_upstream", nil, nil). 180 | GetBranchNames("@main_issue1", nil, nil). 181 | GetMergedBranchNames("@main", nil, nil). 182 | GetRemoteHeadOid(nil, ErrCommand, nil). 183 | GetLsRemoteHeadOid(nil, nil, nil). 184 | GetLog([]conn.LogStub{ 185 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 186 | }, nil, nil). 187 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 188 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 189 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 190 | }, nil, nil). 191 | GetPullRequests("issue1UpMerged", nil, nil). 192 | GetUncommittedChanges("", nil, nil). 193 | GetConfig([]conn.ConfigStub{ 194 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 195 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 196 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 197 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 198 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 199 | }, nil, nil) 200 | remote, _ := GetRemote(context.Background(), s.Conn) 201 | 202 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 203 | 204 | assert.Equal(t, 2, len(actual)) 205 | assert.Equal(t, "issue1", actual[0].Name) 206 | assert.Equal(t, shared.Deletable, actual[0].State) 207 | assert.Equal(t, "main", actual[1].Name) 208 | assert.Equal(t, shared.NotDeletable, actual[1].State) 209 | } 210 | 211 | func Test_DeletableWhenPRCheckoutBranchesAssociatedWithUpstreamSquashAndMergedPR(t *testing.T) { 212 | ctrl := gomock.NewController(t) 213 | defer ctrl.Finish() 214 | 215 | s := conn.Setup(ctrl). 216 | CheckRepos(nil, nil). 217 | GetRemoteNames("origin", nil, nil). 218 | GetSshConfig("github.com", nil, nil). 219 | GetRepoNames("origin_upstream", nil, nil). 220 | GetBranchNames("@main_forkMain", nil, nil). 221 | GetMergedBranchNames("@main", nil, nil). 222 | GetRemoteHeadOid(nil, ErrCommand, nil). 223 | GetLsRemoteHeadOid(nil, nil, nil). 224 | GetLog([]conn.LogStub{ 225 | {BranchName: "main", Filename: "main"}, {BranchName: "fork/main", Filename: "issue1"}, 226 | }, nil, nil). 227 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 228 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "forkMain"}, 229 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_forkMain"}, 230 | }, nil, nil). 231 | GetPullRequests("forkMainUpMerged", nil, nil). 232 | GetUncommittedChanges("", nil, nil). 233 | GetConfig([]conn.ConfigStub{ 234 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 235 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 236 | {BranchName: "branch.fork/main.merge", Filename: "mergeForkMain"}, 237 | {BranchName: "branch.fork/main.remote", Filename: "remote"}, 238 | {BranchName: "branch.fork/main.gh-poi-protected", Filename: "empty"}, 239 | }, nil, nil) 240 | remote, _ := GetRemote(context.Background(), s.Conn) 241 | 242 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 243 | 244 | assert.Equal(t, 2, len(actual)) 245 | assert.Equal(t, "fork/main", actual[0].Name) 246 | assert.Equal(t, shared.Deletable, actual[0].State) 247 | assert.Equal(t, "main", actual[1].Name) 248 | assert.Equal(t, shared.NotDeletable, actual[1].State) 249 | } 250 | 251 | func Test_DeletableWhenBranchIsCheckedOutWithCheckIsFalse(t *testing.T) { 252 | ctrl := gomock.NewController(t) 253 | defer ctrl.Finish() 254 | 255 | s := conn.Setup(ctrl). 256 | CheckRepos(nil, nil). 257 | GetRemoteNames("origin", nil, nil). 258 | GetSshConfig("github.com", nil, nil). 259 | GetRepoNames("origin", nil, nil). 260 | GetBranchNames("main_@issue1", nil, nil). 261 | GetMergedBranchNames("main", nil, nil). 262 | GetRemoteHeadOid(nil, ErrCommand, nil). 263 | GetLsRemoteHeadOid(nil, nil, nil). 264 | GetLog([]conn.LogStub{ 265 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 266 | }, nil, nil). 267 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 268 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 269 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 270 | }, nil, nil). 271 | GetPullRequests("issue1Merged", nil, nil). 272 | GetUncommittedChanges("", nil, nil). 273 | GetConfig([]conn.ConfigStub{ 274 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 275 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 276 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 277 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 278 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 279 | }, nil, nil). 280 | CheckoutBranch(nil, conn.NewConf(&conn.Times{N: 1})) 281 | remote, _ := GetRemote(context.Background(), s.Conn) 282 | 283 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 284 | 285 | assert.Equal(t, 2, len(actual)) 286 | assert.Equal(t, "issue1", actual[0].Name) 287 | assert.Equal(t, shared.Deletable, actual[0].State) 288 | assert.Equal(t, "main", actual[1].Name) 289 | assert.Equal(t, shared.NotDeletable, actual[1].State) 290 | } 291 | 292 | func Test_DeletableWhenBranchIsCheckedOutWithCheckIsTrue(t *testing.T) { 293 | ctrl := gomock.NewController(t) 294 | defer ctrl.Finish() 295 | 296 | s := conn.Setup(ctrl). 297 | CheckRepos(nil, nil). 298 | GetRemoteNames("origin", nil, nil). 299 | GetSshConfig("github.com", nil, nil). 300 | GetRepoNames("origin", nil, nil). 301 | GetBranchNames("main_@issue1", nil, nil). 302 | GetMergedBranchNames("main", nil, nil). 303 | GetRemoteHeadOid(nil, ErrCommand, nil). 304 | GetLsRemoteHeadOid(nil, nil, nil). 305 | GetLog([]conn.LogStub{ 306 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 307 | }, nil, nil). 308 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 309 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 310 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 311 | }, nil, nil). 312 | GetPullRequests("issue1Merged", nil, nil). 313 | GetUncommittedChanges("", nil, nil). 314 | GetConfig([]conn.ConfigStub{ 315 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 316 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 317 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 318 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 319 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 320 | }, nil, nil). 321 | CheckoutBranch(nil, conn.NewConf(&conn.Times{N: 0})) 322 | remote, _ := GetRemote(context.Background(), s.Conn) 323 | 324 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, true) 325 | 326 | assert.Equal(t, 2, len(actual)) 327 | assert.Equal(t, "issue1", actual[0].Name) 328 | assert.Equal(t, shared.Deletable, actual[0].State) 329 | assert.Equal(t, "main", actual[1].Name) 330 | assert.Equal(t, shared.NotDeletable, actual[1].State) 331 | } 332 | 333 | func Test_DeletableWhenBranchIsCheckedOutWithoutDefaultBranch(t *testing.T) { 334 | ctrl := gomock.NewController(t) 335 | defer ctrl.Finish() 336 | 337 | s := conn.Setup(ctrl). 338 | CheckRepos(nil, nil). 339 | GetRemoteNames("origin", nil, nil). 340 | GetSshConfig("github.com", nil, nil). 341 | GetRepoNames("origin", nil, nil). 342 | GetBranchNames("@issue1", nil, nil). 343 | GetMergedBranchNames("empty", nil, nil). 344 | GetRemoteHeadOid(nil, ErrCommand, nil). 345 | GetLsRemoteHeadOid(nil, nil, nil). 346 | GetLog([]conn.LogStub{ 347 | {BranchName: "issue1", Filename: "issue1"}, 348 | }, nil, nil). 349 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 350 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 351 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "issue1_originMain"}, 352 | }, nil, nil). 353 | GetPullRequests("issue1Merged", nil, nil). 354 | GetUncommittedChanges("", nil, nil). 355 | GetConfig([]conn.ConfigStub{ 356 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 357 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 358 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 359 | }, nil, nil). 360 | CheckoutBranch(nil, nil) 361 | remote, _ := GetRemote(context.Background(), s.Conn) 362 | 363 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 364 | 365 | assert.Equal(t, 2, len(actual)) 366 | assert.Equal(t, "issue1", actual[0].Name) 367 | assert.Equal(t, shared.Deletable, actual[0].State) 368 | assert.Equal(t, "main", actual[1].Name) 369 | assert.Equal(t, shared.NotDeletable, actual[1].State) 370 | } 371 | 372 | func Test_NotDeletableWhenBranchHasModifiedUncommittedChanges(t *testing.T) { 373 | ctrl := gomock.NewController(t) 374 | defer ctrl.Finish() 375 | 376 | s := conn.Setup(ctrl). 377 | CheckRepos(nil, nil). 378 | GetRemoteNames("origin", nil, nil). 379 | GetSshConfig("github.com", nil, nil). 380 | GetRepoNames("origin", nil, nil). 381 | GetBranchNames("main_@issue1", nil, nil). 382 | GetMergedBranchNames("main", nil, nil). 383 | GetRemoteHeadOid(nil, ErrCommand, nil). 384 | GetLsRemoteHeadOid(nil, nil, nil). 385 | GetLog([]conn.LogStub{ 386 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 387 | }, nil, nil). 388 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 389 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 390 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 391 | }, nil, nil). 392 | GetPullRequests("issue1Merged", nil, nil). 393 | GetUncommittedChanges(" M README.md", nil, nil). 394 | GetConfig([]conn.ConfigStub{ 395 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 396 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 397 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 398 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 399 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 400 | }, nil, nil). 401 | CheckoutBranch(nil, nil) 402 | remote, _ := GetRemote(context.Background(), s.Conn) 403 | 404 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 405 | 406 | assert.Equal(t, 2, len(actual)) 407 | assert.Equal(t, "issue1", actual[0].Name) 408 | assert.Equal(t, shared.NotDeletable, actual[0].State) 409 | assert.Equal(t, "main", actual[1].Name) 410 | assert.Equal(t, shared.NotDeletable, actual[1].State) 411 | } 412 | 413 | func Test_DeletableWhenBranchHasUntrackedUncommittedChanges(t *testing.T) { 414 | ctrl := gomock.NewController(t) 415 | defer ctrl.Finish() 416 | 417 | s := conn.Setup(ctrl). 418 | CheckRepos(nil, nil). 419 | GetRemoteNames("origin", nil, nil). 420 | GetSshConfig("github.com", nil, nil). 421 | GetRepoNames("origin", nil, nil). 422 | GetBranchNames("main_@issue1", nil, nil). 423 | GetMergedBranchNames("main", nil, nil). 424 | GetRemoteHeadOid(nil, ErrCommand, nil). 425 | GetLsRemoteHeadOid(nil, nil, nil). 426 | GetLog([]conn.LogStub{ 427 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 428 | }, nil, nil). 429 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 430 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 431 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 432 | }, nil, nil). 433 | GetPullRequests("issue1Merged", nil, nil). 434 | GetUncommittedChanges("?? new.txt", nil, nil). 435 | GetConfig([]conn.ConfigStub{ 436 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 437 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 438 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 439 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 440 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 441 | }, nil, nil). 442 | CheckoutBranch(nil, nil) 443 | remote, _ := GetRemote(context.Background(), s.Conn) 444 | 445 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 446 | 447 | assert.Equal(t, 2, len(actual)) 448 | assert.Equal(t, "issue1", actual[0].Name) 449 | assert.Equal(t, shared.Deletable, actual[0].State) 450 | assert.Equal(t, "main", actual[1].Name) 451 | assert.Equal(t, shared.NotDeletable, actual[1].State) 452 | } 453 | 454 | func Test_NotDeletableWhenPRIsClosedAndStateOptionIsMerged(t *testing.T) { 455 | ctrl := gomock.NewController(t) 456 | defer ctrl.Finish() 457 | 458 | s := conn.Setup(ctrl). 459 | CheckRepos(nil, nil). 460 | GetRemoteNames("origin", nil, nil). 461 | GetSshConfig("github.com", nil, nil). 462 | GetRepoNames("origin", nil, nil). 463 | GetBranchNames("@main_issue1", nil, nil). 464 | GetMergedBranchNames("@main", nil, nil). 465 | GetRemoteHeadOid(nil, ErrCommand, nil). 466 | GetLsRemoteHeadOid(nil, nil, nil). 467 | GetLog([]conn.LogStub{ 468 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 469 | }, nil, nil). 470 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 471 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 472 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 473 | }, nil, nil). 474 | GetPullRequests("issue1Closed", nil, nil). 475 | GetUncommittedChanges("", nil, nil). 476 | GetConfig([]conn.ConfigStub{ 477 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 478 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 479 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 480 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 481 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 482 | }, nil, nil) 483 | remote, _ := GetRemote(context.Background(), s.Conn) 484 | 485 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 486 | 487 | assert.Equal(t, 2, len(actual)) 488 | assert.Equal(t, "issue1", actual[0].Name) 489 | assert.Equal(t, shared.NotDeletable, actual[0].State) 490 | assert.Equal(t, "main", actual[1].Name) 491 | assert.Equal(t, shared.NotDeletable, actual[1].State) 492 | } 493 | 494 | func Test_DeletableWhenPRIsClosedAndStateOptionIsClosed(t *testing.T) { 495 | ctrl := gomock.NewController(t) 496 | defer ctrl.Finish() 497 | 498 | s := conn.Setup(ctrl). 499 | CheckRepos(nil, nil). 500 | GetRemoteNames("origin", nil, nil). 501 | GetSshConfig("github.com", nil, nil). 502 | GetRepoNames("origin", nil, nil). 503 | GetBranchNames("@main_issue1", nil, nil). 504 | GetMergedBranchNames("@main", nil, nil). 505 | GetRemoteHeadOid(nil, ErrCommand, nil). 506 | GetLsRemoteHeadOid(nil, nil, nil). 507 | GetLog([]conn.LogStub{ 508 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 509 | }, nil, nil). 510 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 511 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 512 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 513 | }, nil, nil). 514 | GetPullRequests("issue1Closed", nil, nil). 515 | GetUncommittedChanges("", nil, nil). 516 | GetConfig([]conn.ConfigStub{ 517 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 518 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 519 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 520 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 521 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 522 | }, nil, nil) 523 | remote, _ := GetRemote(context.Background(), s.Conn) 524 | 525 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Closed, false) 526 | 527 | assert.Equal(t, 2, len(actual)) 528 | assert.Equal(t, "issue1", actual[0].Name) 529 | assert.Equal(t, shared.Deletable, actual[0].State) 530 | assert.Equal(t, "main", actual[1].Name) 531 | assert.Equal(t, shared.NotDeletable, actual[1].State) 532 | } 533 | 534 | func Test_DeletableWhenPRHasMergedAndClosedAndStateOptionIsMerged(t *testing.T) { 535 | ctrl := gomock.NewController(t) 536 | defer ctrl.Finish() 537 | 538 | s := conn.Setup(ctrl). 539 | CheckRepos(nil, nil). 540 | GetRemoteNames("origin", nil, nil). 541 | GetSshConfig("github.com", nil, nil). 542 | GetRepoNames("origin", nil, nil). 543 | GetBranchNames("@main_issue1", nil, nil). 544 | GetMergedBranchNames("@main", nil, nil). 545 | GetRemoteHeadOid(nil, ErrCommand, nil). 546 | GetLsRemoteHeadOid(nil, nil, nil). 547 | GetLog([]conn.LogStub{ 548 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 549 | }, nil, nil). 550 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 551 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 552 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 553 | }, nil, nil). 554 | GetPullRequests("issue1Merged_issue1Closed", nil, nil). 555 | GetUncommittedChanges("", nil, nil). 556 | GetConfig([]conn.ConfigStub{ 557 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 558 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 559 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 560 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 561 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 562 | }, nil, nil) 563 | remote, _ := GetRemote(context.Background(), s.Conn) 564 | 565 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 566 | 567 | assert.Equal(t, 2, len(actual)) 568 | assert.Equal(t, "issue1", actual[0].Name) 569 | assert.Equal(t, shared.Deletable, actual[0].State) 570 | assert.Equal(t, "main", actual[1].Name) 571 | assert.Equal(t, shared.NotDeletable, actual[1].State) 572 | } 573 | 574 | func Test_DeletableWhenPRHasMergedAndClosedAndStateOptionIsClosed(t *testing.T) { 575 | ctrl := gomock.NewController(t) 576 | defer ctrl.Finish() 577 | 578 | s := conn.Setup(ctrl). 579 | CheckRepos(nil, nil). 580 | GetRemoteNames("origin", nil, nil). 581 | GetSshConfig("github.com", nil, nil). 582 | GetRepoNames("origin", nil, nil). 583 | GetBranchNames("@main_issue1", nil, nil). 584 | GetMergedBranchNames("@main", nil, nil). 585 | GetRemoteHeadOid(nil, ErrCommand, nil). 586 | GetLsRemoteHeadOid(nil, nil, nil). 587 | GetLog([]conn.LogStub{ 588 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 589 | }, nil, nil). 590 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 591 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 592 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 593 | }, nil, nil). 594 | GetPullRequests("issue1Merged_issue1Closed", nil, nil). 595 | GetUncommittedChanges("", nil, nil). 596 | GetConfig([]conn.ConfigStub{ 597 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 598 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 599 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 600 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 601 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 602 | }, nil, nil) 603 | remote, _ := GetRemote(context.Background(), s.Conn) 604 | 605 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Closed, false) 606 | 607 | assert.Equal(t, 2, len(actual)) 608 | assert.Equal(t, "issue1", actual[0].Name) 609 | assert.Equal(t, shared.Deletable, actual[0].State) 610 | assert.Equal(t, "main", actual[1].Name) 611 | assert.Equal(t, shared.NotDeletable, actual[1].State) 612 | } 613 | 614 | func Test_NotDeletableWhenBranchesAssociatedWithNotFullyMergedPR(t *testing.T) { 615 | ctrl := gomock.NewController(t) 616 | defer ctrl.Finish() 617 | 618 | s := conn.Setup(ctrl). 619 | CheckRepos(nil, nil). 620 | GetRemoteNames("origin", nil, nil). 621 | GetSshConfig("github.com", nil, nil). 622 | GetRepoNames("origin", nil, nil). 623 | GetBranchNames("@main_issue1", nil, nil). 624 | GetMergedBranchNames("@main", nil, nil). 625 | GetRemoteHeadOid(nil, ErrCommand, nil). 626 | GetLsRemoteHeadOid(nil, nil, nil). 627 | GetLog([]conn.LogStub{ 628 | {BranchName: "main", Filename: "main_issue1SquashAndMerged"}, {BranchName: "issue1", Filename: "issue1CommitAfterMerge"}, 629 | }, nil, nil). 630 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 631 | {Oid: "cb197ba87e4ad323b1008c611212deb7da2a4a49", Filename: "main"}, 632 | {Oid: "b8a2645298053fb62ea03e27feea6c483d3fd27e", Filename: "issue1"}, 633 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 634 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 635 | }, nil, nil). 636 | GetPullRequests("issue1Merged", nil, nil). 637 | GetUncommittedChanges("", nil, nil). 638 | GetConfig([]conn.ConfigStub{ 639 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 640 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 641 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 642 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 643 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 644 | }, nil, nil) 645 | remote, _ := GetRemote(context.Background(), s.Conn) 646 | 647 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 648 | 649 | assert.Equal(t, 2, len(actual)) 650 | assert.Equal(t, "issue1", actual[0].Name) 651 | assert.Equal(t, shared.NotDeletable, actual[0].State) 652 | assert.Equal(t, "main", actual[1].Name) 653 | assert.Equal(t, shared.NotDeletable, actual[1].State) 654 | } 655 | 656 | func Test_NotDeletableWhenDefaultBranchAssociatedWithMergedPR(t *testing.T) { 657 | ctrl := gomock.NewController(t) 658 | defer ctrl.Finish() 659 | 660 | s := conn.Setup(ctrl). 661 | CheckRepos(nil, nil). 662 | GetRemoteNames("origin", nil, nil). 663 | GetSshConfig("github.com", nil, nil). 664 | GetRepoNames("origin", nil, nil). 665 | GetBranchNames("@main_issue1", nil, nil). 666 | GetMergedBranchNames("@main", nil, nil). 667 | GetRemoteHeadOid(nil, ErrCommand, nil). 668 | GetLsRemoteHeadOid(nil, nil, nil). 669 | GetLog([]conn.LogStub{ 670 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 671 | }, nil, nil). 672 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 673 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 674 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 675 | }, nil, nil). 676 | GetPullRequests("mainMerged", nil, nil). 677 | GetUncommittedChanges("", nil, nil). 678 | GetConfig([]conn.ConfigStub{ 679 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 680 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 681 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 682 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 683 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 684 | }, nil, nil) 685 | remote, _ := GetRemote(context.Background(), s.Conn) 686 | 687 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 688 | 689 | assert.Equal(t, 2, len(actual)) 690 | assert.Equal(t, "issue1", actual[0].Name) 691 | assert.Equal(t, shared.NotDeletable, actual[0].State) 692 | assert.Equal(t, "main", actual[1].Name) 693 | assert.Equal(t, shared.NotDeletable, actual[1].State) 694 | } 695 | 696 | func Test_NotDeletableWhenBranchIsProtected(t *testing.T) { 697 | ctrl := gomock.NewController(t) 698 | defer ctrl.Finish() 699 | 700 | s := conn.Setup(ctrl). 701 | CheckRepos(nil, nil). 702 | GetRemoteNames("origin", nil, nil). 703 | GetSshConfig("github.com", nil, nil). 704 | GetRepoNames("origin", nil, nil). 705 | GetBranchNames("@main_issue1", nil, nil). 706 | GetMergedBranchNames("@main_issue1", nil, nil). 707 | GetRemoteHeadOid([]conn.RemoteHeadStub{ 708 | {BranchName: "issue1", Filename: "issue1"}, 709 | }, nil, nil). 710 | GetLog([]conn.LogStub{ 711 | {BranchName: "main", Filename: "main_issue1Merged"}, {BranchName: "issue1", Filename: "issue1Merged"}, 712 | }, nil, nil). 713 | GetPullRequests("issue1Merged", nil, nil). 714 | GetUncommittedChanges("", nil, nil). 715 | GetConfig([]conn.ConfigStub{ 716 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 717 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 718 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 719 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "protected"}, 720 | }, nil, nil) 721 | remote, _ := GetRemote(context.Background(), s.Conn) 722 | 723 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 724 | 725 | assert.Equal(t, 2, len(actual)) 726 | assert.Equal(t, "issue1", actual[0].Name) 727 | assert.Equal(t, true, actual[0].IsProtected) 728 | assert.Equal(t, shared.NotDeletable, actual[0].State) 729 | assert.Equal(t, "main", actual[1].Name) 730 | assert.Equal(t, false, actual[1].IsProtected) 731 | assert.Equal(t, shared.NotDeletable, actual[1].State) 732 | } 733 | 734 | func Test_BranchesAndPRsAreNotAssociatedWhenManyLocalCommitsAreAhead(t *testing.T) { 735 | ctrl := gomock.NewController(t) 736 | defer ctrl.Finish() 737 | 738 | s := conn.Setup(ctrl). 739 | CheckRepos(nil, nil). 740 | GetRemoteNames("origin", nil, nil). 741 | GetSshConfig("github.com", nil, nil). 742 | GetRepoNames("origin", nil, nil). 743 | GetBranchNames("@main_issue1", nil, nil). 744 | GetMergedBranchNames("@main", nil, nil). 745 | GetRemoteHeadOid(nil, ErrCommand, nil). 746 | GetLsRemoteHeadOid(nil, nil, nil). 747 | GetLog([]conn.LogStub{ 748 | {BranchName: "main", Filename: "main"}, 749 | {BranchName: "issue1", Filename: "issue1ManyCommits"}, // return with '--max-count=3' 750 | }, nil, nil). 751 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 752 | {Oid: "62d5d8280031f607f1db058da959a97f6a8e6d90", Filename: "issue1"}, 753 | {Oid: "b8a2645298053fb62ea03e27feea6c483d3fd27e", Filename: "issue1"}, 754 | {Oid: "d787669ee4a103fe0b361fe31c10ea037c72f27c", Filename: "issue1"}, 755 | }, nil, nil). 756 | GetPullRequests("notFound", nil, nil). 757 | GetUncommittedChanges("", nil, nil). 758 | GetConfig([]conn.ConfigStub{ 759 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 760 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 761 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 762 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 763 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 764 | }, nil, nil) 765 | remote, _ := GetRemote(context.Background(), s.Conn) 766 | 767 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 768 | 769 | assert.Equal(t, 2, len(actual)) 770 | assert.Equal(t, "issue1", actual[0].Name) 771 | assert.Equal(t, []shared.PullRequest{}, actual[0].PullRequests) 772 | assert.Equal(t, shared.NotDeletable, actual[0].State) 773 | assert.Equal(t, "main", actual[1].Name) 774 | assert.Equal(t, shared.NotDeletable, actual[1].State) 775 | } 776 | 777 | func Test_NoCommitHistoryWhenFirstCommitOfTopicBranchIsAssociatedWithDefaultBranch(t *testing.T) { 778 | ctrl := gomock.NewController(t) 779 | defer ctrl.Finish() 780 | 781 | s := conn.Setup(ctrl). 782 | CheckRepos(nil, nil). 783 | GetRemoteNames("origin", nil, nil). 784 | GetSshConfig("github.com", nil, nil). 785 | GetRepoNames("origin", nil, nil). 786 | GetBranchNames("@main_issue1", nil, nil). 787 | GetMergedBranchNames("@main", nil, nil). 788 | GetRemoteHeadOid(nil, ErrCommand, nil). 789 | GetLsRemoteHeadOid(nil, nil, nil). 790 | GetLog([]conn.LogStub{ 791 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "main"}, 792 | }, nil, nil). 793 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 794 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 795 | }, nil, nil). 796 | GetPullRequests("notFound", nil, nil). 797 | GetUncommittedChanges("", nil, nil). 798 | GetConfig([]conn.ConfigStub{ 799 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 800 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 801 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 802 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 803 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 804 | }, nil, nil) 805 | remote, _ := GetRemote(context.Background(), s.Conn) 806 | 807 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 808 | 809 | assert.Equal(t, 2, len(actual)) 810 | assert.Equal(t, "issue1", actual[0].Name) 811 | assert.Equal(t, []string{}, actual[0].Commits) 812 | assert.Equal(t, shared.NotDeletable, actual[0].State) 813 | assert.Equal(t, "main", actual[1].Name) 814 | assert.Equal(t, shared.NotDeletable, actual[1].State) 815 | } 816 | 817 | func Test_NoCommitHistoryWhenDetachedBranch(t *testing.T) { 818 | ctrl := gomock.NewController(t) 819 | defer ctrl.Finish() 820 | 821 | s := conn.Setup(ctrl). 822 | CheckRepos(nil, nil). 823 | GetRemoteNames("origin", nil, nil). 824 | GetSshConfig("github.com", nil, nil). 825 | GetRepoNames("origin", nil, nil). 826 | GetBranchNames("main_@detached", nil, nil). 827 | GetMergedBranchNames("main", nil, nil). 828 | GetRemoteHeadOid(nil, ErrCommand, nil). 829 | GetLsRemoteHeadOid(nil, nil, nil). 830 | GetLog([]conn.LogStub{ 831 | {BranchName: "main", Filename: "main"}, 832 | }, nil, nil). 833 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 834 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 835 | }, nil, nil). 836 | GetPullRequests("notFound", nil, nil). 837 | GetUncommittedChanges("", nil, nil). 838 | GetConfig([]conn.ConfigStub{ 839 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 840 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 841 | {BranchName: "branch.(HEAD detached at a97e963).gh-poi-protected", Filename: "empty"}, 842 | }, nil, nil) 843 | remote, _ := GetRemote(context.Background(), s.Conn) 844 | 845 | actual, _ := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 846 | 847 | assert.Equal(t, 2, len(actual)) 848 | assert.Equal(t, "(HEAD detached at a97e963)", actual[0].Name) 849 | assert.Equal(t, []string{}, actual[0].Commits) 850 | assert.Equal(t, shared.NotDeletable, actual[0].State) 851 | assert.Equal(t, "main", actual[1].Name) 852 | assert.Equal(t, shared.NotDeletable, actual[1].State) 853 | } 854 | 855 | func Test_ReturnsErrorWhenGetRemoteNamesFails(t *testing.T) { 856 | ctrl := gomock.NewController(t) 857 | defer ctrl.Finish() 858 | 859 | s := conn.Setup(ctrl). 860 | GetRemoteNames("origin", ErrCommand, nil) 861 | 862 | _, err := GetRemote(context.Background(), s.Conn) 863 | 864 | assert.NotNil(t, err) 865 | } 866 | 867 | func Test_DoesNotReturnsErrorWhenGetSshConfigFails(t *testing.T) { 868 | ctrl := gomock.NewController(t) 869 | defer ctrl.Finish() 870 | 871 | s := conn.Setup(ctrl). 872 | CheckRepos(nil, nil). 873 | GetRemoteNames("origin", nil, nil). 874 | GetSshConfig("github.com", ErrCommand, nil). 875 | GetRepoNames("origin", nil, nil). 876 | GetBranchNames("@main_issue1", nil, nil). 877 | GetMergedBranchNames("@main", nil, nil). 878 | GetRemoteHeadOid(nil, ErrCommand, nil). 879 | GetLsRemoteHeadOid(nil, nil, nil). 880 | GetLog([]conn.LogStub{ 881 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 882 | }, nil, nil). 883 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 884 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 885 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 886 | }, nil, nil). 887 | GetPullRequests("issue1Merged", nil, nil). 888 | GetUncommittedChanges("", nil, nil). 889 | GetConfig([]conn.ConfigStub{ 890 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 891 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 892 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 893 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 894 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 895 | }, nil, nil) 896 | remote, _ := GetRemote(context.Background(), s.Conn) 897 | 898 | _, err := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 899 | 900 | assert.Nil(t, err) 901 | } 902 | 903 | func Test_ReturnsErrorWhenGetRepoNamesFails(t *testing.T) { 904 | ctrl := gomock.NewController(t) 905 | defer ctrl.Finish() 906 | 907 | s := conn.Setup(ctrl). 908 | GetRemoteNames("origin", nil, nil). 909 | GetSshConfig("github.com", nil, nil). 910 | GetRepoNames("origin", ErrCommand, nil) 911 | remote, _ := GetRemote(context.Background(), s.Conn) 912 | 913 | _, err := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 914 | 915 | assert.NotNil(t, err) 916 | } 917 | 918 | func Test_ReturnsErrorWhenCheckReposFails(t *testing.T) { 919 | ctrl := gomock.NewController(t) 920 | defer ctrl.Finish() 921 | 922 | s := conn.Setup(ctrl). 923 | CheckRepos(ErrCommand, nil). 924 | GetRemoteNames("origin", nil, nil). 925 | GetSshConfig("github.com", nil, nil). 926 | GetRepoNames("origin", nil, nil) 927 | remote, _ := GetRemote(context.Background(), s.Conn) 928 | 929 | _, err := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 930 | 931 | assert.NotNil(t, err) 932 | } 933 | 934 | func Test_ReturnsErrorWhenGetBranchNamesFails(t *testing.T) { 935 | ctrl := gomock.NewController(t) 936 | defer ctrl.Finish() 937 | 938 | s := conn.Setup(ctrl). 939 | CheckRepos(nil, nil). 940 | GetRemoteNames("origin", nil, nil). 941 | GetSshConfig("github.com", nil, nil). 942 | GetRepoNames("origin", nil, nil). 943 | GetBranchNames("@main_issue1", ErrCommand, nil) 944 | remote, _ := GetRemote(context.Background(), s.Conn) 945 | 946 | _, err := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 947 | 948 | assert.NotNil(t, err) 949 | } 950 | 951 | func Test_ReturnsErrorWhenGetMergedBranchNames(t *testing.T) { 952 | ctrl := gomock.NewController(t) 953 | defer ctrl.Finish() 954 | 955 | s := conn.Setup(ctrl). 956 | CheckRepos(nil, nil). 957 | GetRemoteNames("origin", nil, nil). 958 | GetSshConfig("github.com", nil, nil). 959 | GetRepoNames("origin", nil, nil). 960 | GetBranchNames("@main_issue1", nil, nil). 961 | GetMergedBranchNames("@main", ErrCommand, nil) 962 | remote, _ := GetRemote(context.Background(), s.Conn) 963 | 964 | _, err := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 965 | 966 | assert.NotNil(t, err) 967 | } 968 | 969 | func Test_ReturnsErrorWhenGetLogFails(t *testing.T) { 970 | ctrl := gomock.NewController(t) 971 | defer ctrl.Finish() 972 | 973 | s := conn.Setup(ctrl). 974 | CheckRepos(nil, nil). 975 | GetRemoteNames("origin", nil, nil). 976 | GetSshConfig("github.com", nil, nil). 977 | GetRepoNames("origin", nil, nil). 978 | GetBranchNames("@main_issue1", nil, nil). 979 | GetMergedBranchNames("@main", nil, nil). 980 | GetRemoteHeadOid(nil, ErrCommand, nil). 981 | GetLsRemoteHeadOid(nil, nil, nil). 982 | GetLog([]conn.LogStub{ 983 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 984 | }, ErrCommand, nil). 985 | GetConfig([]conn.ConfigStub{ 986 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 987 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 988 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 989 | }, nil, nil) 990 | remote, _ := GetRemote(context.Background(), s.Conn) 991 | 992 | _, err := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 993 | 994 | assert.NotNil(t, err) 995 | } 996 | 997 | func Test_ReturnsErrorWhenGetAssociatedRefNamesFails(t *testing.T) { 998 | ctrl := gomock.NewController(t) 999 | defer ctrl.Finish() 1000 | 1001 | s := conn.Setup(ctrl). 1002 | CheckRepos(nil, nil). 1003 | GetRemoteNames("origin", nil, nil). 1004 | GetSshConfig("github.com", nil, nil). 1005 | GetRepoNames("origin", nil, nil). 1006 | GetBranchNames("@main_issue1", nil, nil). 1007 | GetMergedBranchNames("@main", nil, nil). 1008 | GetRemoteHeadOid(nil, ErrCommand, nil). 1009 | GetLsRemoteHeadOid(nil, nil, nil). 1010 | GetLog([]conn.LogStub{ 1011 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 1012 | }, nil, nil). 1013 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 1014 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 1015 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 1016 | }, ErrCommand, nil). 1017 | GetConfig([]conn.ConfigStub{ 1018 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 1019 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 1020 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 1021 | }, nil, nil) 1022 | remote, _ := GetRemote(context.Background(), s.Conn) 1023 | 1024 | _, err := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 1025 | 1026 | assert.NotNil(t, err) 1027 | } 1028 | 1029 | func Test_ReturnsErrorWhenGetPullRequestsFails(t *testing.T) { 1030 | ctrl := gomock.NewController(t) 1031 | defer ctrl.Finish() 1032 | 1033 | s := conn.Setup(ctrl). 1034 | CheckRepos(nil, nil). 1035 | GetRemoteNames("origin", nil, nil). 1036 | GetSshConfig("github.com", nil, nil). 1037 | GetRepoNames("origin", nil, nil). 1038 | GetBranchNames("@main_issue1", nil, nil). 1039 | GetMergedBranchNames("@main", nil, nil). 1040 | GetRemoteHeadOid(nil, ErrCommand, nil). 1041 | GetLsRemoteHeadOid(nil, nil, nil). 1042 | GetLog([]conn.LogStub{ 1043 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 1044 | }, nil, nil). 1045 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 1046 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 1047 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 1048 | }, nil, nil). 1049 | GetPullRequests("issue1Merged", ErrCommand, nil). 1050 | GetUncommittedChanges("", nil, nil). 1051 | GetConfig([]conn.ConfigStub{ 1052 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 1053 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 1054 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 1055 | }, nil, nil) 1056 | remote, _ := GetRemote(context.Background(), s.Conn) 1057 | 1058 | _, err := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 1059 | 1060 | assert.NotNil(t, err) 1061 | } 1062 | 1063 | func Test_ReturnsErrorWhenGetUncommittedChangesFails(t *testing.T) { 1064 | ctrl := gomock.NewController(t) 1065 | defer ctrl.Finish() 1066 | 1067 | s := conn.Setup(ctrl). 1068 | CheckRepos(nil, nil). 1069 | GetRemoteNames("origin", nil, nil). 1070 | GetSshConfig("github.com", nil, nil). 1071 | GetRepoNames("origin", nil, nil). 1072 | GetBranchNames("@main_issue1", nil, nil). 1073 | GetMergedBranchNames("@main", nil, nil). 1074 | GetRemoteHeadOid(nil, ErrCommand, nil). 1075 | GetLsRemoteHeadOid(nil, nil, nil). 1076 | GetLog([]conn.LogStub{ 1077 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 1078 | }, nil, nil). 1079 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 1080 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 1081 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 1082 | }, nil, nil). 1083 | GetPullRequests("issue1Merged", nil, nil). 1084 | GetUncommittedChanges("", ErrCommand, nil). 1085 | GetConfig([]conn.ConfigStub{ 1086 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 1087 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 1088 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 1089 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 1090 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 1091 | }, nil, nil) 1092 | remote, _ := GetRemote(context.Background(), s.Conn) 1093 | 1094 | _, err := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 1095 | 1096 | assert.NotNil(t, err) 1097 | } 1098 | 1099 | func Test_ReturnsErrorWhenCheckoutBranchFails(t *testing.T) { 1100 | ctrl := gomock.NewController(t) 1101 | defer ctrl.Finish() 1102 | 1103 | s := conn.Setup(ctrl). 1104 | CheckRepos(nil, nil). 1105 | GetRemoteNames("origin", nil, nil). 1106 | GetSshConfig("github.com", nil, nil). 1107 | GetRepoNames("origin", nil, nil). 1108 | GetBranchNames("main_@issue1", nil, nil). 1109 | GetMergedBranchNames("main", nil, nil). 1110 | GetRemoteHeadOid(nil, ErrCommand, nil). 1111 | GetLsRemoteHeadOid(nil, nil, nil). 1112 | GetLog([]conn.LogStub{ 1113 | {BranchName: "main", Filename: "main"}, {BranchName: "issue1", Filename: "issue1"}, 1114 | }, nil, nil). 1115 | GetAssociatedRefNames([]conn.AssociatedBranchNamesStub{ 1116 | {Oid: "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0", Filename: "issue1"}, 1117 | {Oid: "6ebe3d30d23531af56bd23b5a098d3ccae2a534a", Filename: "main_issue1"}, 1118 | }, nil, nil). 1119 | GetPullRequests("issue1Merged", nil, nil). 1120 | GetUncommittedChanges("", nil, nil). 1121 | CheckoutBranch(ErrCommand, nil). 1122 | GetConfig([]conn.ConfigStub{ 1123 | {BranchName: "branch.main.merge", Filename: "mergeMain"}, 1124 | {BranchName: "branch.main.gh-poi-protected", Filename: "empty"}, 1125 | {BranchName: "branch.issue1.merge", Filename: "mergeIssue1"}, 1126 | {BranchName: "branch.issue1.remote", Filename: "remote"}, 1127 | {BranchName: "branch.issue1.gh-poi-protected", Filename: "empty"}, 1128 | }, nil, nil) 1129 | remote, _ := GetRemote(context.Background(), s.Conn) 1130 | 1131 | _, err := GetBranches(context.Background(), remote, s.Conn, shared.Merged, false) 1132 | 1133 | assert.NotNil(t, err) 1134 | } 1135 | 1136 | func Test_DeletingDeletableBranches(t *testing.T) { 1137 | ctrl := gomock.NewController(t) 1138 | defer ctrl.Finish() 1139 | 1140 | s := conn.Setup(ctrl). 1141 | GetBranchNames("@main", nil, nil). 1142 | DeleteBranches(nil, conn.NewConf(&conn.Times{N: 1})) 1143 | 1144 | branches := []shared.Branch{ 1145 | {Head: false, Name: "issue1", IsMerged: false, IsProtected: false, RemoteHeadOid: "", Commits: []string{}, PullRequests: []shared.PullRequest{}, State: shared.Deletable}, 1146 | {Head: true, Name: "main", IsMerged: true, IsProtected: false, RemoteHeadOid: "", Commits: []string{}, PullRequests: []shared.PullRequest{}, State: shared.NotDeletable}, 1147 | } 1148 | 1149 | actual, _ := DeleteBranches(context.Background(), branches, s.Conn) 1150 | 1151 | assert.Equal(t, 2, len(actual)) 1152 | assert.Equal(t, "issue1", actual[0].Name) 1153 | assert.Equal(t, shared.Deleted, actual[0].State) 1154 | assert.Equal(t, "main", actual[1].Name) 1155 | assert.Equal(t, shared.NotDeletable, actual[1].State) 1156 | } 1157 | 1158 | func Test_DoNotDeleteNotDeletableBranches(t *testing.T) { 1159 | ctrl := gomock.NewController(t) 1160 | defer ctrl.Finish() 1161 | 1162 | s := conn.Setup(ctrl). 1163 | DeleteBranches(nil, conn.NewConf(&conn.Times{N: 0})) 1164 | 1165 | branches := []shared.Branch{ 1166 | {Head: false, Name: "issue1", IsMerged: false, IsProtected: false, RemoteHeadOid: "", Commits: []string{}, PullRequests: []shared.PullRequest{}, State: shared.NotDeletable}, 1167 | {Head: true, Name: "main", IsMerged: true, IsProtected: false, RemoteHeadOid: "", Commits: []string{}, PullRequests: []shared.PullRequest{}, State: shared.NotDeletable}, 1168 | } 1169 | 1170 | actual, _ := DeleteBranches(context.Background(), branches, s.Conn) 1171 | 1172 | assert.Equal(t, 2, len(actual)) 1173 | assert.Equal(t, "issue1", actual[0].Name) 1174 | assert.Equal(t, shared.NotDeletable, actual[0].State) 1175 | assert.Equal(t, "main", actual[1].Name) 1176 | assert.Equal(t, shared.NotDeletable, actual[1].State) 1177 | } 1178 | -------------------------------------------------------------------------------- /conn/command.go: -------------------------------------------------------------------------------- 1 | package conn 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "time" 11 | 12 | "github.com/cli/safeexec" 13 | ) 14 | 15 | type ( 16 | Connection struct { 17 | Debug bool 18 | } 19 | 20 | DebugMask int 21 | ) 22 | 23 | const ( 24 | None DebugMask = iota 25 | Output 26 | ) 27 | 28 | func (conn *Connection) CheckRepos(ctx context.Context, hostname string, repoNames []string) error { 29 | for _, name := range repoNames { 30 | args := []string{ 31 | "api", 32 | "--hostname", hostname, 33 | "repos/" + name, 34 | "--silent", 35 | } 36 | if _, err := conn.run(ctx, "gh", args, None); err != nil { 37 | return err 38 | } 39 | } 40 | return nil 41 | } 42 | 43 | func (conn *Connection) GetRemoteNames(ctx context.Context) (string, error) { 44 | args := []string{ 45 | "remote", "-v", 46 | } 47 | return conn.run(ctx, "git", args, None) 48 | } 49 | 50 | func (conn *Connection) GetSshConfig(ctx context.Context, name string) (string, error) { 51 | args := []string{ 52 | "-T", "-G", name, 53 | } 54 | return conn.run(ctx, "ssh", args, Output) 55 | } 56 | 57 | func (conn *Connection) GetRepoNames(ctx context.Context, hostname string, repoName string) (string, error) { 58 | args := []string{ 59 | "repo", "view", hostname + "/" + repoName, 60 | "--json", "owner,name,parent,defaultBranchRef", 61 | } 62 | return conn.run(ctx, "gh", args, None) 63 | } 64 | 65 | func (conn *Connection) GetBranchNames(ctx context.Context) (string, error) { 66 | args := []string{ 67 | "branch", "-v", "--no-abbrev", 68 | "--format=%(HEAD):%(refname:lstrip=2):%(objectname)", 69 | } 70 | return conn.run(ctx, "git", args, None) 71 | } 72 | 73 | func (conn *Connection) GetMergedBranchNames(ctx context.Context, remoteName string, branchName string) (string, error) { 74 | args := []string{ 75 | "branch", "--merged", fmt.Sprintf("%s/%s", remoteName, branchName), 76 | } 77 | return conn.run(ctx, "git", args, None) 78 | } 79 | 80 | func (conn *Connection) GetRemoteHeadOid(ctx context.Context, remoteName string, branchName string) (string, error) { 81 | args := []string{ 82 | "rev-parse", fmt.Sprintf("%s/%s", remoteName, branchName), 83 | } 84 | return conn.run(ctx, "git", args, None) 85 | } 86 | 87 | func (conn *Connection) GetLsRemoteHeadOid(ctx context.Context, url string, branchName string) (string, error) { 88 | args := []string{ 89 | "ls-remote", url, branchName, 90 | } 91 | return conn.run(ctx, "git", args, None) 92 | } 93 | 94 | func (conn *Connection) GetLog(ctx context.Context, branchName string) (string, error) { 95 | args := []string{ 96 | "log", "--first-parent", "--max-count=30", "--format=%H", branchName, "--", 97 | } 98 | return conn.run(ctx, "git", args, None) 99 | } 100 | 101 | func (conn *Connection) GetAssociatedRefNames(ctx context.Context, oid string) (string, error) { 102 | args := []string{ 103 | "branch", "--all", "--format=%(refname)", 104 | "--contains", oid, 105 | } 106 | return conn.run(ctx, "git", args, None) 107 | } 108 | 109 | // limitations: 110 | // - https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests#search-within-a-users-or-organizations-repositories 111 | // - https://docs.github.com/en/graphql/overview/resource-limitations 112 | func (conn *Connection) GetPullRequests( 113 | ctx context.Context, 114 | hostname string, orgs string, repos string, queryHashes string) (string, error) { 115 | args := []string{ 116 | "api", "graphql", 117 | "--hostname", hostname, 118 | "-f", fmt.Sprintf(`query=query { 119 | search(type: ISSUE, query: "is:pr %s %s %s", last: 100) { 120 | issueCount 121 | edges { 122 | node { 123 | ... on PullRequest { 124 | number 125 | url 126 | state 127 | isDraft 128 | headRefName 129 | commits(last: 100) { 130 | nodes { 131 | commit { 132 | oid 133 | } 134 | } 135 | } 136 | author { login } 137 | } 138 | } 139 | } 140 | } 141 | }`, 142 | orgs, repos, queryHashes, 143 | ), 144 | } 145 | return conn.run(ctx, "gh", args, None) 146 | } 147 | 148 | func (conn *Connection) GetUncommittedChanges(ctx context.Context) (string, error) { 149 | args := []string{ 150 | "status", "--short", 151 | } 152 | return conn.run(ctx, "git", args, None) 153 | } 154 | 155 | func (conn *Connection) GetConfig(ctx context.Context, key string) (string, error) { 156 | args := []string{ 157 | "config", "--get", key, 158 | } 159 | return conn.run(ctx, "git", args, None) 160 | } 161 | 162 | func (conn *Connection) AddConfig(ctx context.Context, key string, value string) (string, error) { 163 | args := []string{ 164 | "config", "--add", key, value, 165 | } 166 | return conn.run(ctx, "git", args, None) 167 | } 168 | 169 | func (conn *Connection) RemoveConfig(ctx context.Context, key string) (string, error) { 170 | args := []string{ 171 | "config", "--unset", key, 172 | } 173 | return conn.run(ctx, "git", args, None) 174 | } 175 | 176 | func (conn *Connection) CheckoutBranch(ctx context.Context, branchName string) (string, error) { 177 | args := []string{ 178 | "checkout", "--quiet", branchName, 179 | } 180 | return conn.run(ctx, "git", args, None) 181 | } 182 | 183 | func (conn *Connection) DeleteBranches(ctx context.Context, branchNames []string) (string, error) { 184 | args := append([]string{ 185 | "branch", "-D"}, 186 | branchNames..., 187 | ) 188 | return conn.run(ctx, "git", args, None) 189 | } 190 | 191 | func (conn *Connection) PruneRemoteBranches(ctx context.Context, remoteName string) (string, error) { 192 | args := []string{ 193 | "remote", "prune", remoteName, 194 | } 195 | return conn.run(ctx, "git", args, None) 196 | } 197 | 198 | func (conn *Connection) run(ctx context.Context, name string, args []string, mask DebugMask) (string, error) { 199 | cmdPath, err := safeexec.LookPath(name) 200 | if err != nil { 201 | return "", err 202 | } 203 | 204 | var stdout bytes.Buffer 205 | cmd := exec.CommandContext(ctx, cmdPath, args...) 206 | cmd.Stdout = &stdout 207 | if name == "gh" { 208 | cmd.Env = append(os.Environ(), "CLICOLOR_FORCE=0") 209 | } 210 | 211 | start := time.Now() 212 | err = cmd.Run() 213 | duration := time.Since(start) 214 | if err != nil { 215 | err = fmt.Errorf("failed to run external command: %s, args: %v\n %w", name, args, err) 216 | return "", err 217 | } 218 | 219 | if conn.Debug { 220 | switch mask { 221 | case None: 222 | log.Printf("[%v] run %s %v -> %q\n", duration, name, args, stdout.String()) 223 | case Output: 224 | log.Printf("[%v] run %s %v -> *****\n", duration, name, args) 225 | } 226 | } 227 | 228 | return stdout.String(), err 229 | } 230 | -------------------------------------------------------------------------------- /conn/command_test.go: -------------------------------------------------------------------------------- 1 | package conn 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | // $ git log --all --graph --pretty=oneline 13 | // * a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 (issue1) 1-1 14 | // * 6ebe3d30d23531af56bd23b5a098d3ccae2a534a (HEAD -> main) Initial commit 15 | func Test_RepoBasic(t *testing.T) { 16 | setGitDir("repo_basic", t) 17 | conn := &Connection{} 18 | stub := &Stub{nil, t} 19 | 20 | t.Run("GetRemoteNames", func(t *testing.T) { 21 | actual, _ := conn.GetRemoteNames(context.Background()) 22 | assert.Equal(t, 23 | stub.readFile("git", "remote", "origin"), 24 | actual, 25 | ) 26 | }) 27 | 28 | t.Run("GetBranchNames", func(t *testing.T) { 29 | actual, _ := conn.GetBranchNames(context.Background()) 30 | assert.Equal(t, 31 | stub.readFile("git", "branch", "@main_issue1"), 32 | actual, 33 | ) 34 | }) 35 | 36 | t.Run("GetLog", func(t *testing.T) { 37 | 38 | t.Run("main", func(t *testing.T) { 39 | actual, _ := conn.GetLog(context.Background(), "main") 40 | assert.Equal(t, 41 | stub.readFile("git", "log", "main"), 42 | actual, 43 | ) 44 | }) 45 | 46 | t.Run("issue1", func(t *testing.T) { 47 | actual, _ := conn.GetLog(context.Background(), "issue1") 48 | assert.Equal(t, 49 | stub.readFile("git", "log", "issue1"), 50 | actual, 51 | ) 52 | }) 53 | }) 54 | 55 | t.Run("GetAssociatedRefNames", func(t *testing.T) { 56 | 57 | t.Run("issue1", func(t *testing.T) { 58 | actual, _ := conn.GetAssociatedRefNames(context.Background(), "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0") 59 | assert.Equal(t, 60 | stub.readFile("git", "abranch", "issue1"), 61 | actual, 62 | ) 63 | }) 64 | 65 | t.Run("main_issue1", func(t *testing.T) { 66 | actual, _ := conn.GetAssociatedRefNames(context.Background(), "6ebe3d30d23531af56bd23b5a098d3ccae2a534a") 67 | assert.Equal(t, 68 | stub.readFile("git", "abranch", "main_issue1"), 69 | actual, 70 | ) 71 | }) 72 | }) 73 | 74 | t.Run("GetUncommittedChanges", func(t *testing.T) { 75 | actual, _ := conn.GetUncommittedChanges(context.Background()) 76 | assert.Equal(t, "A README.md\n", actual) 77 | }) 78 | 79 | t.Run("GetConfig", func(t *testing.T) { 80 | actual, _ := conn.GetConfig(context.Background(), "branch.main.merge") 81 | assert.Equal(t, 82 | stub.readFile("git", "config", "mergeMain"), 83 | actual, 84 | ) 85 | }) 86 | 87 | t.Run("AddConfig", func(t *testing.T) { 88 | conn.AddConfig(context.Background(), "branch.issue2.gh-poi-protected", "true") 89 | actual, _ := conn.GetConfig(context.Background(), "branch.issue2.gh-poi-protected") 90 | assert.Equal(t, 91 | stub.readFile("git", "config", "protected"), 92 | actual, 93 | ) 94 | conn.RemoveConfig(context.Background(), "branch.issue2.gh-poi-protected") 95 | }) 96 | 97 | t.Run("AddAndRemoveConfig", func(t *testing.T) { 98 | conn.AddConfig(context.Background(), "branch.issue2.gh-poi-protected", "true") 99 | conn.RemoveConfig(context.Background(), "branch.issue2.gh-poi-protected") 100 | actual, _ := conn.GetConfig(context.Background(), "branch.issue2.gh-poi-protected") 101 | assert.Equal(t, 102 | stub.readFile("git", "config", "empty"), 103 | actual, 104 | ) 105 | }) 106 | } 107 | 108 | func setGitDir(repoName string, t *testing.T) { 109 | gitDirOrg := os.Getenv("GIT_DIR") 110 | gitWorkTreeOrg := os.Getenv("GIT_WORK_TREE") 111 | 112 | os.Setenv("GIT_DIR", filepath.Join(fixturePath, repoName, ".git")) 113 | os.Setenv("GIT_WORK_TREE", filepath.Join(fixturePath, repoName)) 114 | 115 | t.Cleanup(func() { 116 | os.Setenv("GIT_DIR", gitDirOrg) 117 | os.Setenv("GIT_WORK_TREE", gitWorkTreeOrg) 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /conn/fixtures/.gitignore: -------------------------------------------------------------------------------- 1 | # If it's a directory, it will be recognized as a submodule, so zip it up. 2 | /repo_basic 3 | -------------------------------------------------------------------------------- /conn/fixtures/README.md: -------------------------------------------------------------------------------- 1 | ## zip 2 | 3 | `zip -r repo_basic.zip repo_basic -x "*.DS_Store"` 4 | -------------------------------------------------------------------------------- /conn/fixtures/gh/pr_forkMainUpMerged.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "search": { 4 | "issueCount": 1, 5 | "edges": [ 6 | { 7 | "node": { 8 | "number": 1, 9 | "url": "https://github.com/parent-owner/repo/pull/1", 10 | "state": "MERGED", 11 | "isDraft": false, 12 | "headRefName": "main", 13 | "commits": { 14 | "nodes": [ 15 | { 16 | "commit": { 17 | "oid": "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0" 18 | } 19 | } 20 | ] 21 | }, 22 | "author": { 23 | "login": "owner" 24 | } 25 | } 26 | } 27 | ] 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /conn/fixtures/gh/pr_issue1Closed.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "search": { 4 | "issueCount": 1, 5 | "edges": [ 6 | { 7 | "node": { 8 | "number": 1, 9 | "url": "https://github.com/owner/repo/pull/1", 10 | "state": "CLOSED", 11 | "isDraft": false, 12 | "headRefName": "issue1", 13 | "commits": { 14 | "nodes": [ 15 | { 16 | "commit": { 17 | "oid": "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0" 18 | } 19 | } 20 | ] 21 | }, 22 | "author": { 23 | "login": "owner" 24 | } 25 | } 26 | } 27 | ] 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /conn/fixtures/gh/pr_issue1Merged.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "search": { 4 | "issueCount": 1, 5 | "edges": [ 6 | { 7 | "node": { 8 | "number": 1, 9 | "url": "https://github.com/owner/repo/pull/1", 10 | "state": "MERGED", 11 | "isDraft": false, 12 | "headRefName": "issue1", 13 | "commits": { 14 | "nodes": [ 15 | { 16 | "commit": { 17 | "oid": "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0" 18 | } 19 | } 20 | ] 21 | }, 22 | "author": { 23 | "login": "owner" 24 | } 25 | } 26 | } 27 | ] 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /conn/fixtures/gh/pr_issue1Merged_issue1Closed.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "search": { 4 | "issueCount": 2, 5 | "edges": [ 6 | { 7 | "node": { 8 | "number": 1, 9 | "url": "https://github.com/owner/repo/pull/1", 10 | "state": "CLOSED", 11 | "isDraft": false, 12 | "headRefName": "issue1", 13 | "commits": { 14 | "nodes": [ 15 | { 16 | "commit": { 17 | "oid": "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0" 18 | } 19 | } 20 | ] 21 | }, 22 | "author": { 23 | "login": "owner" 24 | } 25 | } 26 | }, 27 | { 28 | "node": { 29 | "number": 2, 30 | "url": "https://github.com/owner/repo/pull/2", 31 | "state": "MERGED", 32 | "isDraft": false, 33 | "headRefName": "issue1", 34 | "commits": { 35 | "nodes": [ 36 | { 37 | "commit": { 38 | "oid": "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0" 39 | } 40 | } 41 | ] 42 | }, 43 | "author": { 44 | "login": "owner" 45 | } 46 | } 47 | } 48 | ] 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /conn/fixtures/gh/pr_issue1UpMerged.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "search": { 4 | "issueCount": 1, 5 | "edges": [ 6 | { 7 | "node": { 8 | "number": 1, 9 | "url": "https://github.com/parent-owner/repo/pull/1", 10 | "state": "MERGED", 11 | "isDraft": false, 12 | "headRefName": "issue1", 13 | "commits": { 14 | "nodes": [ 15 | { 16 | "commit": { 17 | "oid": "a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0" 18 | } 19 | } 20 | ] 21 | }, 22 | "author": { 23 | "login": "owner" 24 | } 25 | } 26 | } 27 | ] 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /conn/fixtures/gh/pr_mainMerged.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "search": { 4 | "issueCount": 1, 5 | "edges": [ 6 | { 7 | "node": { 8 | "number": 1, 9 | "url": "https://github.com/owner/repo/pull/1", 10 | "state": "MERGED", 11 | "isDraft": false, 12 | "headRefName": "main", 13 | "commits": { 14 | "nodes": [ 15 | { 16 | "commit": { 17 | "oid": "6ebe3d30d23531af56bd23b5a098d3ccae2a534a" 18 | } 19 | } 20 | ] 21 | }, 22 | "author": { 23 | "login": "owner" 24 | } 25 | } 26 | } 27 | ] 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /conn/fixtures/gh/pr_notFound.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "search": { 4 | "issueCount": 0, 5 | "edges": [] 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /conn/fixtures/gh/repo_origin.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultBranchRef": { 3 | "name": "main" 4 | }, 5 | "name": "repo", 6 | "owner": { 7 | "id": "1", 8 | "login": "owner" 9 | }, 10 | "parent": null 11 | } 12 | -------------------------------------------------------------------------------- /conn/fixtures/gh/repo_origin_upstream.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultBranchRef": { 3 | "name": "main" 4 | }, 5 | "name": "repo", 6 | "owner": { 7 | "id": "1", 8 | "login": "owner" 9 | }, 10 | "parent": { 11 | "id": "2", 12 | "name": "repo", 13 | "owner": { 14 | "id": "3", 15 | "login": "parent-owner" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /conn/fixtures/git/abranch_forkMain.txt: -------------------------------------------------------------------------------- 1 | refs/heads/fork/main 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/abranch_issue1.txt: -------------------------------------------------------------------------------- 1 | refs/heads/issue1 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/abranch_issue1_originMain.txt: -------------------------------------------------------------------------------- 1 | refs/heads/issue1 2 | refs/remotes/origin/main 3 | -------------------------------------------------------------------------------- /conn/fixtures/git/abranch_main.txt: -------------------------------------------------------------------------------- 1 | refs/heads/main 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/abranch_main_forkMain.txt: -------------------------------------------------------------------------------- 1 | refs/heads/fork/main 2 | refs/heads/main 3 | -------------------------------------------------------------------------------- /conn/fixtures/git/abranch_main_issue1.txt: -------------------------------------------------------------------------------- 1 | refs/heads/issue1 2 | refs/heads/main 3 | -------------------------------------------------------------------------------- /conn/fixtures/git/branchMerged_@main.txt: -------------------------------------------------------------------------------- 1 | * main 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/branchMerged_@main_issue1.txt: -------------------------------------------------------------------------------- 1 | issue1 2 | * main 3 | -------------------------------------------------------------------------------- /conn/fixtures/git/branchMerged_empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seachicken/gh-poi/ec902154f1d18a72c4d774abb316b6fbc9eb8007/conn/fixtures/git/branchMerged_empty.txt -------------------------------------------------------------------------------- /conn/fixtures/git/branchMerged_main.txt: -------------------------------------------------------------------------------- 1 | main 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/branch_@issue1.txt: -------------------------------------------------------------------------------- 1 | *:issue1:a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/branch_@main.txt: -------------------------------------------------------------------------------- 1 | *:main:6ebe3d30d23531af56bd23b5a098d3ccae2a534a 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/branch_@main_forkMain.txt: -------------------------------------------------------------------------------- 1 | :fork/main:a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 2 | *:main:6ebe3d30d23531af56bd23b5a098d3ccae2a534a 3 | -------------------------------------------------------------------------------- /conn/fixtures/git/branch_@main_issue1.txt: -------------------------------------------------------------------------------- 1 | :issue1:a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 2 | *:main:6ebe3d30d23531af56bd23b5a098d3ccae2a534a 3 | -------------------------------------------------------------------------------- /conn/fixtures/git/branch_main_@detached.txt: -------------------------------------------------------------------------------- 1 | *:(HEAD detached at a97e963):a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 2 | :main:6ebe3d30d23531af56bd23b5a098d3ccae2a534a 3 | -------------------------------------------------------------------------------- /conn/fixtures/git/branch_main_@issue1.txt: -------------------------------------------------------------------------------- 1 | *:issue1:a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 2 | :main:6ebe3d30d23531af56bd23b5a098d3ccae2a534a 3 | -------------------------------------------------------------------------------- /conn/fixtures/git/config_empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seachicken/gh-poi/ec902154f1d18a72c4d774abb316b6fbc9eb8007/conn/fixtures/git/config_empty.txt -------------------------------------------------------------------------------- /conn/fixtures/git/config_mergeForkMain.txt: -------------------------------------------------------------------------------- 1 | refs/pull/1/head 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/config_mergeIssue1.txt: -------------------------------------------------------------------------------- 1 | refs/heads/issue1 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/config_mergeMain.txt: -------------------------------------------------------------------------------- 1 | refs/heads/main 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/config_protected.txt: -------------------------------------------------------------------------------- 1 | true 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/config_remote.txt: -------------------------------------------------------------------------------- 1 | git@github.com:owner/repo.git 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/log_issue1.txt: -------------------------------------------------------------------------------- 1 | a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 2 | 6ebe3d30d23531af56bd23b5a098d3ccae2a534a 3 | -------------------------------------------------------------------------------- /conn/fixtures/git/log_issue1CommitAfterMerge.txt: -------------------------------------------------------------------------------- 1 | b8a2645298053fb62ea03e27feea6c483d3fd27e 2 | a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 3 | 6ebe3d30d23531af56bd23b5a098d3ccae2a534a 4 | -------------------------------------------------------------------------------- /conn/fixtures/git/log_issue1ManyCommits.txt: -------------------------------------------------------------------------------- 1 | 62d5d8280031f607f1db058da959a97f6a8e6d90 2 | b8a2645298053fb62ea03e27feea6c483d3fd27e 3 | d787669ee4a103fe0b361fe31c10ea037c72f27c 4 | -------------------------------------------------------------------------------- /conn/fixtures/git/log_issue1Merged.txt: -------------------------------------------------------------------------------- 1 | a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 2 | 6ebe3d30d23531af56bd23b5a098d3ccae2a534a 3 | -------------------------------------------------------------------------------- /conn/fixtures/git/log_main.txt: -------------------------------------------------------------------------------- 1 | 6ebe3d30d23531af56bd23b5a098d3ccae2a534a 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/log_main_issue1Merged.txt: -------------------------------------------------------------------------------- 1 | b8a2645298053fb62ea03e27feea6c483d3fd27e 2 | a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 3 | 6ebe3d30d23531af56bd23b5a098d3ccae2a534a 4 | -------------------------------------------------------------------------------- /conn/fixtures/git/log_main_issue1SquashAndMerged.txt: -------------------------------------------------------------------------------- 1 | cb197ba87e4ad323b1008c611212deb7da2a4a49 2 | 6ebe3d30d23531af56bd23b5a098d3ccae2a534a 3 | -------------------------------------------------------------------------------- /conn/fixtures/git/lsRemoteHead_issue1.txt: -------------------------------------------------------------------------------- 1 | a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 refs/heads/issue1 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/remoteHead_issue1.txt: -------------------------------------------------------------------------------- 1 | a97e9630426df5d34ca9ee77ae1159bdfd5ff8f0 2 | -------------------------------------------------------------------------------- /conn/fixtures/git/remote_origin.txt: -------------------------------------------------------------------------------- 1 | origin git@github.com:owner/repo.git (fetch) 2 | origin git@github.com:owner/repo.git (push) 3 | -------------------------------------------------------------------------------- /conn/fixtures/repo_basic.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seachicken/gh-poi/ec902154f1d18a72c4d774abb316b6fbc9eb8007/conn/fixtures/repo_basic.zip -------------------------------------------------------------------------------- /conn/fixtures/ssh/config_github.com.txt: -------------------------------------------------------------------------------- 1 | user owner 2 | hostname github.com 3 | -------------------------------------------------------------------------------- /conn/stub.go: -------------------------------------------------------------------------------- 1 | package conn 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | 8 | "github.com/golang/mock/gomock" 9 | "github.com/seachicken/gh-poi/mocks" 10 | ) 11 | 12 | type ( 13 | Stub struct { 14 | Conn *mocks.MockConnection 15 | t gomock.TestHelper 16 | } 17 | 18 | Times struct { 19 | N int 20 | } 21 | 22 | Conf struct { 23 | Times *Times 24 | } 25 | 26 | RemoteHeadStub struct { 27 | BranchName string 28 | Filename string 29 | } 30 | 31 | LsRemoteHeadStub struct { 32 | BranchName string 33 | Filename string 34 | } 35 | 36 | AssociatedBranchNamesStub struct { 37 | Oid string 38 | Filename string 39 | } 40 | 41 | LogStub struct { 42 | BranchName string 43 | Filename string 44 | } 45 | 46 | ConfigStub struct { 47 | BranchName string 48 | Filename string 49 | } 50 | ) 51 | 52 | var ( 53 | fixturePath = "fixtures" 54 | ) 55 | 56 | func Setup(ctrl *gomock.Controller) *Stub { 57 | conn := mocks.NewMockConnection(ctrl) 58 | return &Stub{conn, ctrl.T} 59 | } 60 | 61 | func NewConf(times *Times) *Conf { 62 | return &Conf{ 63 | times, 64 | } 65 | } 66 | 67 | func (s *Stub) CheckRepos(err error, conf *Conf) *Stub { 68 | s.t.Helper() 69 | configure( 70 | s.Conn. 71 | EXPECT(). 72 | CheckRepos(gomock.Any(), gomock.Any(), gomock.Any()). 73 | Return(err), 74 | conf, 75 | ) 76 | return s 77 | } 78 | 79 | func (s *Stub) GetRemoteNames(filename string, err error, conf *Conf) *Stub { 80 | s.t.Helper() 81 | configure( 82 | s.Conn. 83 | EXPECT(). 84 | GetRemoteNames(gomock.Any()). 85 | Return(s.readFile("git", "remote", filename), err), 86 | conf, 87 | ) 88 | return s 89 | } 90 | 91 | func (s *Stub) GetSshConfig(filename string, err error, conf *Conf) *Stub { 92 | s.t.Helper() 93 | configure( 94 | s.Conn. 95 | EXPECT(). 96 | GetSshConfig(gomock.Any(), gomock.Any()). 97 | Return(s.readFile("ssh", "config", filename), err), 98 | conf, 99 | ) 100 | return s 101 | } 102 | 103 | func (s *Stub) GetRepoNames(filename string, err error, conf *Conf) *Stub { 104 | s.t.Helper() 105 | configure( 106 | s.Conn. 107 | EXPECT(). 108 | GetRepoNames(gomock.Any(), gomock.Any(), gomock.Any()). 109 | Return(s.readFile("gh", "repo", filename), err), 110 | conf, 111 | ) 112 | return s 113 | } 114 | 115 | func (s *Stub) GetBranchNames(filename string, err error, conf *Conf) *Stub { 116 | s.t.Helper() 117 | configure( 118 | s.Conn.EXPECT(). 119 | GetBranchNames(gomock.Any()). 120 | Return(s.readFile("git", "branch", filename), err), 121 | conf, 122 | ) 123 | return s 124 | } 125 | 126 | func (s *Stub) GetMergedBranchNames(filename string, err error, conf *Conf) *Stub { 127 | s.t.Helper() 128 | configure( 129 | s.Conn.EXPECT(). 130 | GetMergedBranchNames(gomock.Any(), "origin", "main"). 131 | Return(s.readFile("git", "branchMerged", filename), err), 132 | conf, 133 | ) 134 | return s 135 | } 136 | 137 | func (s *Stub) GetRemoteHeadOid(stubs []RemoteHeadStub, err error, conf *Conf) *Stub { 138 | s.t.Helper() 139 | if stubs == nil { 140 | configure( 141 | s.Conn.EXPECT(). 142 | GetRemoteHeadOid(gomock.Any(), gomock.Any(), gomock.Any()). 143 | Return("", err), 144 | conf, 145 | ) 146 | } else { 147 | for _, stub := range stubs { 148 | configure( 149 | s.Conn.EXPECT(). 150 | GetRemoteHeadOid(gomock.Any(), gomock.Any(), stub.BranchName). 151 | Return(s.readFile("git", "remoteHead", stub.Filename), err), 152 | conf, 153 | ) 154 | } 155 | } 156 | return s 157 | } 158 | 159 | func (s *Stub) GetLsRemoteHeadOid(stubs []LsRemoteHeadStub, err error, conf *Conf) *Stub { 160 | s.t.Helper() 161 | if stubs == nil { 162 | configure( 163 | s.Conn.EXPECT(). 164 | GetLsRemoteHeadOid(gomock.Any(), gomock.Any(), gomock.Any()). 165 | Return("", err), 166 | conf, 167 | ) 168 | } else { 169 | for _, stub := range stubs { 170 | configure( 171 | s.Conn.EXPECT(). 172 | GetLsRemoteHeadOid(gomock.Any(), gomock.Any(), stub.BranchName). 173 | Return(s.readFile("git", "lsRemoteHead", stub.Filename), err), 174 | conf, 175 | ) 176 | } 177 | } 178 | return s 179 | } 180 | 181 | func (s *Stub) GetAssociatedRefNames(stubs []AssociatedBranchNamesStub, err error, conf *Conf) *Stub { 182 | s.t.Helper() 183 | for _, stub := range stubs { 184 | configure( 185 | s.Conn.EXPECT(). 186 | GetAssociatedRefNames(gomock.Any(), stub.Oid). 187 | Return(s.readFile("git", "abranch", stub.Filename), err), 188 | conf, 189 | ) 190 | } 191 | return s 192 | } 193 | 194 | func (s *Stub) GetLog(stubs []LogStub, err error, conf *Conf) *Stub { 195 | s.t.Helper() 196 | for _, stub := range stubs { 197 | configure( 198 | s.Conn.EXPECT(). 199 | GetLog(gomock.Any(), stub.BranchName). 200 | Return(s.readFile("git", "log", stub.Filename), err), 201 | conf, 202 | ) 203 | } 204 | return s 205 | } 206 | 207 | func (s *Stub) GetPullRequests(filename string, err error, conf *Conf) *Stub { 208 | s.t.Helper() 209 | configure( 210 | s.Conn. 211 | EXPECT(). 212 | GetPullRequests(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). 213 | Return(s.readFile("gh", "pr", filename), err), 214 | conf, 215 | ) 216 | return s 217 | } 218 | 219 | func (s *Stub) GetUncommittedChanges(uncommittedChanges string, err error, conf *Conf) *Stub { 220 | s.t.Helper() 221 | configure( 222 | s.Conn. 223 | EXPECT(). 224 | GetUncommittedChanges(gomock.Any()). 225 | Return(uncommittedChanges, err), 226 | conf, 227 | ) 228 | return s 229 | } 230 | 231 | func (s *Stub) GetConfig(stubs []ConfigStub, err error, conf *Conf) *Stub { 232 | s.t.Helper() 233 | for _, stub := range stubs { 234 | configure( 235 | s.Conn. 236 | EXPECT(). 237 | GetConfig(gomock.Any(), stub.BranchName). 238 | Return(s.readFile("git", "config", stub.Filename), err), 239 | conf, 240 | ) 241 | } 242 | return s 243 | } 244 | 245 | func (s *Stub) CheckoutBranch(err error, conf *Conf) *Stub { 246 | s.t.Helper() 247 | configure( 248 | s.Conn. 249 | EXPECT(). 250 | CheckoutBranch(gomock.Any(), gomock.Any()). 251 | Return("", err), 252 | conf, 253 | ) 254 | return s 255 | } 256 | 257 | func (s *Stub) DeleteBranches(err error, conf *Conf) *Stub { 258 | s.t.Helper() 259 | configure( 260 | s.Conn. 261 | EXPECT(). 262 | DeleteBranches(gomock.Any(), gomock.Any()). 263 | Return("", err), 264 | conf, 265 | ) 266 | return s 267 | } 268 | 269 | func configure(call *gomock.Call, conf *Conf) { 270 | if conf == nil || conf.Times == nil { 271 | call.AnyTimes() 272 | } else { 273 | call.Times(conf.Times.N) 274 | } 275 | } 276 | 277 | func (s *Stub) readFile(command string, category string, name string) string { 278 | _, filename, _, _ := runtime.Caller(0) 279 | 280 | ext := ".txt" 281 | if command == "gh" { 282 | ext = ".json" 283 | } 284 | b, err := os.ReadFile(filepath.Join(filename, "..", fixturePath, command, category+"_"+name+ext)) 285 | if err != nil { 286 | s.t.Fatalf("%v", err) 287 | } 288 | return string(b) 289 | } 290 | -------------------------------------------------------------------------------- /gh-poi: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | rootPath="$(dirname "$0")" 4 | 5 | if ! type -p go >/dev/null; then 6 | echo "Go not found on the system" >&2 7 | exit 1 8 | fi 9 | 10 | (cd $rootPath && go build -o gh-poi.out) 11 | exec "$rootPath/gh-poi.out" "$@" 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/seachicken/gh-poi 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/briandowns/spinner v1.18.1 9 | github.com/cli/safeexec v1.0.1 10 | github.com/fatih/color v1.13.0 11 | github.com/golang/mock v1.6.0 12 | github.com/pkg/errors v0.9.1 13 | github.com/stretchr/testify v1.7.0 14 | ) 15 | 16 | require ( 17 | github.com/davecgh/go-spew v1.1.0 // indirect 18 | github.com/mattn/go-colorable v0.1.9 // indirect 19 | github.com/mattn/go-isatty v0.0.14 // indirect 20 | github.com/pmezard/go-difflib v1.0.0 // indirect 21 | golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c // indirect 22 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= 2 | github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= 3 | github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= 4 | github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= 5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 8 | github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= 9 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 10 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 11 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 12 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 13 | github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= 14 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 15 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 16 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 17 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 18 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 19 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 20 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 24 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 25 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 26 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 27 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 28 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 29 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 30 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 31 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 32 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 33 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 34 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 35 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 36 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 37 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 38 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 39 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 40 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 41 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 42 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c h1:DHcbWVXeY+0Y8HHKR+rbLwnoh2F4tNCY7rTiHJ30RmA= 45 | golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 47 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 48 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 49 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 50 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 51 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 52 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 53 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 54 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 55 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 56 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 57 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 58 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 59 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "slices" 10 | "time" 11 | 12 | "github.com/briandowns/spinner" 13 | "github.com/fatih/color" 14 | "github.com/pkg/errors" 15 | "github.com/seachicken/gh-poi/cmd" 16 | "github.com/seachicken/gh-poi/cmd/protect" 17 | "github.com/seachicken/gh-poi/conn" 18 | "github.com/seachicken/gh-poi/shared" 19 | ) 20 | 21 | var ( 22 | bold = color.New(color.Bold).SprintFunc() 23 | hiBlack = color.New(color.FgHiBlack).SprintFunc() 24 | green = color.New(color.FgGreen).SprintFunc() 25 | red = color.New(color.FgRed).SprintFunc() 26 | ) 27 | 28 | type StateFlag string 29 | 30 | const ( 31 | Closed StateFlag = "closed" 32 | Merged StateFlag = "merged" 33 | ) 34 | 35 | func (s *StateFlag) String() string { 36 | return string(*s) 37 | } 38 | 39 | func (s *StateFlag) Set(value string) error { 40 | for _, state := range []StateFlag{Closed, Merged} { 41 | if value == string(state) { 42 | *s = StateFlag(value) 43 | return nil 44 | } 45 | } 46 | return errors.New("invalid state") 47 | } 48 | 49 | func (s StateFlag) toModel() shared.PullRequestState { 50 | switch s { 51 | case Closed: 52 | return shared.Closed 53 | default: 54 | return shared.Merged 55 | } 56 | } 57 | 58 | func main() { 59 | state := Merged 60 | var dryRun bool 61 | var debug bool 62 | flag.Var(&state, "state", "Specify the PR state to delete by {closed|merged}") 63 | flag.BoolVar(&dryRun, "dry-run", false, "Show branches to delete without actually deleting it") 64 | flag.BoolVar(&debug, "debug", false, "Enable debug logs") 65 | flag.Usage = func() { 66 | fmt.Fprintf(color.Output, "%s\n\n", "Delete the merged local branches.") 67 | fmt.Fprintf(color.Output, "%s\n", bold("USAGE")) 68 | fmt.Fprintf(color.Output, " %s\n\n", "gh poi [flags]") 69 | fmt.Fprintf(color.Output, "%s", bold("COMMANDS")) 70 | fmt.Fprintf(color.Output, "%s\n", ` 71 | protect: Protect local branches from deletion 72 | unprotect: Unprotect local branches 73 | `) 74 | fmt.Fprintf(color.Output, "%s\n", bold("FLAGS")) 75 | flag.PrintDefaults() 76 | fmt.Println() 77 | } 78 | flag.Parse() 79 | args := flag.Args() 80 | 81 | if len(args) == 0 { 82 | runMain(state, dryRun, debug) 83 | } else { 84 | subcmd, args := args[0], args[1:] 85 | switch subcmd { 86 | case "protect": 87 | protectCmd := flag.NewFlagSet("protect", flag.ExitOnError) 88 | protectCmd.Usage = func() { 89 | fmt.Fprintf(color.Output, "%s\n\n", "Protect local branches from deletion.") 90 | fmt.Fprintf(color.Output, "%s\n", bold("USAGE")) 91 | fmt.Fprintf(color.Output, " %s\n\n", "gh poi protect ...") 92 | } 93 | protectCmd.Parse(args) 94 | 95 | runProtect(args, debug) 96 | case "unprotect": 97 | unprotectCmd := flag.NewFlagSet("unprotect", flag.ExitOnError) 98 | unprotectCmd.Usage = func() { 99 | fmt.Fprintf(color.Output, "%s\n\n", "Unprotect local branches.") 100 | fmt.Fprintf(color.Output, "%s\n", bold("USAGE")) 101 | fmt.Fprintf(color.Output, " %s\n\n", "gh poi unprotect ...") 102 | } 103 | unprotectCmd.Parse(args) 104 | 105 | runUnprotect(args, debug) 106 | default: 107 | fmt.Fprintf(os.Stderr, "unknown command %q for poi\n", subcmd) 108 | } 109 | } 110 | } 111 | 112 | func runMain(state StateFlag, dryRun bool, debug bool) { 113 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 114 | defer stop() 115 | 116 | if dryRun { 117 | fmt.Fprintf(color.Output, "%s\n", bold("== DRY RUN ==")) 118 | } 119 | 120 | connection := &conn.Connection{Debug: debug} 121 | sp := spinner.New(spinner.CharSets[14], 40*time.Millisecond) 122 | defer sp.Stop() 123 | 124 | fetchingMsg := " Fetching pull requests..." 125 | sp.Suffix = fetchingMsg 126 | if !debug { 127 | sp.Start() 128 | } 129 | var fetchingErr error 130 | 131 | remote, err := cmd.GetRemote(ctx, connection) 132 | if err != nil { 133 | fmt.Fprintln(os.Stderr, err) 134 | return 135 | } 136 | 137 | branches, fetchingErr := cmd.GetBranches(ctx, remote, connection, state.toModel(), dryRun) 138 | 139 | sp.Stop() 140 | 141 | if fetchingErr == nil { 142 | fmt.Fprintf(color.Output, "%s%s\n", green("✔"), fetchingMsg) 143 | } else { 144 | fmt.Fprintf(color.Output, "%s%s\n", red("✕"), fetchingMsg) 145 | fmt.Fprintln(os.Stderr, fetchingErr) 146 | return 147 | } 148 | 149 | deletingMsg := " Deleting branches..." 150 | var deletingErr error 151 | 152 | if dryRun { 153 | fmt.Fprintf(color.Output, "%s%s\n", hiBlack("-"), deletingMsg) 154 | } else { 155 | sp.Suffix = deletingMsg 156 | if !debug { 157 | sp.Restart() 158 | } 159 | 160 | branches, deletingErr = cmd.DeleteBranches(ctx, branches, connection) 161 | connection.PruneRemoteBranches(ctx, remote.Name) 162 | 163 | sp.Stop() 164 | 165 | if deletingErr == nil { 166 | fmt.Fprintf(color.Output, "%s%s\n", green("✔"), deletingMsg) 167 | } else { 168 | fmt.Fprintf(color.Output, "%s%s\n", red("✕"), deletingMsg) 169 | fmt.Fprintln(os.Stderr, deletingErr) 170 | return 171 | } 172 | } 173 | 174 | fmt.Println() 175 | 176 | var deletedStates []shared.BranchState 177 | var notDeletedStates []shared.BranchState 178 | if dryRun { 179 | deletedStates = []shared.BranchState{shared.Deletable} 180 | notDeletedStates = []shared.BranchState{shared.NotDeletable} 181 | } else { 182 | deletedStates = []shared.BranchState{shared.Deleted} 183 | notDeletedStates = []shared.BranchState{shared.Deletable, shared.NotDeletable} 184 | } 185 | 186 | fmt.Fprintf(color.Output, "%s\n", bold("Deleted branches")) 187 | printBranches(getBranches(branches, deletedStates)) 188 | fmt.Println() 189 | 190 | fmt.Fprintf(color.Output, "%s\n", bold("Branches not deleted")) 191 | printBranches(getBranches(branches, notDeletedStates)) 192 | fmt.Println() 193 | } 194 | 195 | func runProtect(branchNames []string, debug bool) { 196 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 197 | defer stop() 198 | 199 | connection := &conn.Connection{Debug: debug} 200 | 201 | err := protect.ProtectBranches(ctx, branchNames, connection) 202 | if err != nil { 203 | fmt.Fprintln(os.Stderr, err) 204 | return 205 | } 206 | } 207 | 208 | func runUnprotect(branchNames []string, debug bool) { 209 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 210 | defer stop() 211 | 212 | connection := &conn.Connection{Debug: debug} 213 | 214 | err := protect.UnprotectBranches(ctx, branchNames, connection) 215 | if err != nil { 216 | fmt.Fprintln(os.Stderr, err) 217 | return 218 | } 219 | } 220 | 221 | func printBranches(branches []shared.Branch) { 222 | if len(branches) == 0 { 223 | fmt.Fprintf(color.Output, "%s\n", 224 | hiBlack(" There are no branches in the current directory")) 225 | } 226 | 227 | for _, branch := range branches { 228 | if branch.Head { 229 | fmt.Fprintf(color.Output, "* %s", green(branch.Name)) 230 | } else { 231 | fmt.Fprintf(color.Output, " %s", branch.Name) 232 | } 233 | reason := "" 234 | if branch.IsProtected { 235 | reason = "protected" 236 | } 237 | if !branch.IsDefault && len(branch.PullRequests) > 0 && branch.HasTrackedChanges { 238 | reason = "uncommitted changes" 239 | } 240 | if reason == "" { 241 | fmt.Fprintln(color.Output, "") 242 | } else { 243 | fmt.Fprintf(color.Output, " %s\n", hiBlack("["+reason+"]")) 244 | } 245 | 246 | for i, pr := range branch.PullRequests { 247 | number := fmt.Sprintf("#%v", pr.Number) 248 | issueNoColor := getIssueNoColor(pr.State, pr.IsDraft) 249 | var line string 250 | if i == len(branch.PullRequests)-1 { 251 | line = "└─" 252 | } else { 253 | line = "├─" 254 | } 255 | 256 | fmt.Fprintf(color.Output, " %s %s %s %s\n", 257 | line, 258 | color.New(issueNoColor).SprintFunc()(number), 259 | pr.Url, 260 | hiBlack(pr.Author), 261 | ) 262 | } 263 | } 264 | } 265 | 266 | func getIssueNoColor(state shared.PullRequestState, isDraft bool) color.Attribute { 267 | switch state { 268 | case shared.Open: 269 | if isDraft { 270 | return color.FgHiBlack 271 | } else { 272 | return color.FgGreen 273 | } 274 | case shared.Merged: 275 | return color.FgMagenta 276 | case shared.Closed: 277 | return color.FgRed 278 | default: 279 | return color.FgHiBlack 280 | } 281 | } 282 | 283 | func getBranches(branches []shared.Branch, states []shared.BranchState) []shared.Branch { 284 | results := []shared.Branch{} 285 | for _, branch := range branches { 286 | if slices.Contains(states, branch.State) { 287 | results = append(results, branch) 288 | } 289 | } 290 | return results 291 | } 292 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "testing" 9 | 10 | "github.com/fatih/color" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func Test_DeletingBranchesWhenDryRunOptionIsFalse(t *testing.T) { 15 | onlyCI(t) 16 | 17 | results := captureOutput(func() { runMain(Merged, false, false) }) 18 | 19 | expected := fmt.Sprintf("%s %s", green("✔"), "Deleting branches...") 20 | assert.Contains(t, results, expected) 21 | } 22 | 23 | func Test_DoNotDeleteBranchesWhenDryRunOptionIsTrue(t *testing.T) { 24 | onlyCI(t) 25 | 26 | results := captureOutput(func() { runMain(Merged, true, false) }) 27 | 28 | expected := fmt.Sprintf("%s %s", hiBlack("-"), "Deleting branches...") 29 | assert.Contains(t, results, expected) 30 | } 31 | 32 | func Test_ProtectAndUnprotect(t *testing.T) { 33 | onlyCI(t) 34 | 35 | runProtect([]string{"main"}, false) 36 | protectResults := captureOutput(func() { runMain(Merged, true, false) }) 37 | expected := fmt.Sprintf("main %s", hiBlack("[protected]")) 38 | assert.Contains(t, protectResults, expected) 39 | 40 | runUnprotect([]string{"main"}, false) 41 | unprotectResults := captureOutput(func() { runMain(Merged, true, false) }) 42 | assert.NotContains(t, unprotectResults, expected) 43 | } 44 | 45 | func onlyCI(t *testing.T) { 46 | if os.Getenv("CI") == "" { 47 | t.Skip("skipping test in local") 48 | } 49 | 50 | os.Chdir("ci-test") 51 | } 52 | 53 | func captureOutput(f func()) string { 54 | org := os.Stdout 55 | defer func() { 56 | os.Stdout = org 57 | }() 58 | 59 | r, w, _ := os.Pipe() 60 | os.Stdout = w 61 | color.Output = w 62 | 63 | f() 64 | 65 | w.Close() 66 | var buf bytes.Buffer 67 | io.Copy(&buf, r) 68 | 69 | return buf.String() 70 | } 71 | -------------------------------------------------------------------------------- /mocks/poi_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: connection.go 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockConnection is a mock of Connection interface. 15 | type MockConnection struct { 16 | ctrl *gomock.Controller 17 | recorder *MockConnectionMockRecorder 18 | } 19 | 20 | // MockConnectionMockRecorder is the mock recorder for MockConnection. 21 | type MockConnectionMockRecorder struct { 22 | mock *MockConnection 23 | } 24 | 25 | // NewMockConnection creates a new mock instance. 26 | func NewMockConnection(ctrl *gomock.Controller) *MockConnection { 27 | mock := &MockConnection{ctrl: ctrl} 28 | mock.recorder = &MockConnectionMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockConnection) EXPECT() *MockConnectionMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // AddConfig mocks base method. 38 | func (m *MockConnection) AddConfig(ctx context.Context, key, value string) (string, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "AddConfig", ctx, key, value) 41 | ret0, _ := ret[0].(string) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // AddConfig indicates an expected call of AddConfig. 47 | func (mr *MockConnectionMockRecorder) AddConfig(ctx, key, value interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddConfig", reflect.TypeOf((*MockConnection)(nil).AddConfig), ctx, key, value) 50 | } 51 | 52 | // CheckRepos mocks base method. 53 | func (m *MockConnection) CheckRepos(ctx context.Context, hostname string, repoNames []string) error { 54 | m.ctrl.T.Helper() 55 | ret := m.ctrl.Call(m, "CheckRepos", ctx, hostname, repoNames) 56 | ret0, _ := ret[0].(error) 57 | return ret0 58 | } 59 | 60 | // CheckRepos indicates an expected call of CheckRepos. 61 | func (mr *MockConnectionMockRecorder) CheckRepos(ctx, hostname, repoNames interface{}) *gomock.Call { 62 | mr.mock.ctrl.T.Helper() 63 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckRepos", reflect.TypeOf((*MockConnection)(nil).CheckRepos), ctx, hostname, repoNames) 64 | } 65 | 66 | // CheckoutBranch mocks base method. 67 | func (m *MockConnection) CheckoutBranch(ctx context.Context, branchName string) (string, error) { 68 | m.ctrl.T.Helper() 69 | ret := m.ctrl.Call(m, "CheckoutBranch", ctx, branchName) 70 | ret0, _ := ret[0].(string) 71 | ret1, _ := ret[1].(error) 72 | return ret0, ret1 73 | } 74 | 75 | // CheckoutBranch indicates an expected call of CheckoutBranch. 76 | func (mr *MockConnectionMockRecorder) CheckoutBranch(ctx, branchName interface{}) *gomock.Call { 77 | mr.mock.ctrl.T.Helper() 78 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckoutBranch", reflect.TypeOf((*MockConnection)(nil).CheckoutBranch), ctx, branchName) 79 | } 80 | 81 | // DeleteBranches mocks base method. 82 | func (m *MockConnection) DeleteBranches(ctx context.Context, branchNames []string) (string, error) { 83 | m.ctrl.T.Helper() 84 | ret := m.ctrl.Call(m, "DeleteBranches", ctx, branchNames) 85 | ret0, _ := ret[0].(string) 86 | ret1, _ := ret[1].(error) 87 | return ret0, ret1 88 | } 89 | 90 | // DeleteBranches indicates an expected call of DeleteBranches. 91 | func (mr *MockConnectionMockRecorder) DeleteBranches(ctx, branchNames interface{}) *gomock.Call { 92 | mr.mock.ctrl.T.Helper() 93 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBranches", reflect.TypeOf((*MockConnection)(nil).DeleteBranches), ctx, branchNames) 94 | } 95 | 96 | // GetAssociatedRefNames mocks base method. 97 | func (m *MockConnection) GetAssociatedRefNames(ctx context.Context, oid string) (string, error) { 98 | m.ctrl.T.Helper() 99 | ret := m.ctrl.Call(m, "GetAssociatedRefNames", ctx, oid) 100 | ret0, _ := ret[0].(string) 101 | ret1, _ := ret[1].(error) 102 | return ret0, ret1 103 | } 104 | 105 | // GetAssociatedRefNames indicates an expected call of GetAssociatedRefNames. 106 | func (mr *MockConnectionMockRecorder) GetAssociatedRefNames(ctx, oid interface{}) *gomock.Call { 107 | mr.mock.ctrl.T.Helper() 108 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAssociatedRefNames", reflect.TypeOf((*MockConnection)(nil).GetAssociatedRefNames), ctx, oid) 109 | } 110 | 111 | // GetBranchNames mocks base method. 112 | func (m *MockConnection) GetBranchNames(ctx context.Context) (string, error) { 113 | m.ctrl.T.Helper() 114 | ret := m.ctrl.Call(m, "GetBranchNames", ctx) 115 | ret0, _ := ret[0].(string) 116 | ret1, _ := ret[1].(error) 117 | return ret0, ret1 118 | } 119 | 120 | // GetBranchNames indicates an expected call of GetBranchNames. 121 | func (mr *MockConnectionMockRecorder) GetBranchNames(ctx interface{}) *gomock.Call { 122 | mr.mock.ctrl.T.Helper() 123 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBranchNames", reflect.TypeOf((*MockConnection)(nil).GetBranchNames), ctx) 124 | } 125 | 126 | // GetConfig mocks base method. 127 | func (m *MockConnection) GetConfig(ctx context.Context, key string) (string, error) { 128 | m.ctrl.T.Helper() 129 | ret := m.ctrl.Call(m, "GetConfig", ctx, key) 130 | ret0, _ := ret[0].(string) 131 | ret1, _ := ret[1].(error) 132 | return ret0, ret1 133 | } 134 | 135 | // GetConfig indicates an expected call of GetConfig. 136 | func (mr *MockConnectionMockRecorder) GetConfig(ctx, key interface{}) *gomock.Call { 137 | mr.mock.ctrl.T.Helper() 138 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConfig", reflect.TypeOf((*MockConnection)(nil).GetConfig), ctx, key) 139 | } 140 | 141 | // GetLog mocks base method. 142 | func (m *MockConnection) GetLog(ctx context.Context, branchName string) (string, error) { 143 | m.ctrl.T.Helper() 144 | ret := m.ctrl.Call(m, "GetLog", ctx, branchName) 145 | ret0, _ := ret[0].(string) 146 | ret1, _ := ret[1].(error) 147 | return ret0, ret1 148 | } 149 | 150 | // GetLog indicates an expected call of GetLog. 151 | func (mr *MockConnectionMockRecorder) GetLog(ctx, branchName interface{}) *gomock.Call { 152 | mr.mock.ctrl.T.Helper() 153 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLog", reflect.TypeOf((*MockConnection)(nil).GetLog), ctx, branchName) 154 | } 155 | 156 | // GetLsRemoteHeadOid mocks base method. 157 | func (m *MockConnection) GetLsRemoteHeadOid(ctx context.Context, url, branchName string) (string, error) { 158 | m.ctrl.T.Helper() 159 | ret := m.ctrl.Call(m, "GetLsRemoteHeadOid", ctx, url, branchName) 160 | ret0, _ := ret[0].(string) 161 | ret1, _ := ret[1].(error) 162 | return ret0, ret1 163 | } 164 | 165 | // GetLsRemoteHeadOid indicates an expected call of GetLsRemoteHeadOid. 166 | func (mr *MockConnectionMockRecorder) GetLsRemoteHeadOid(ctx, url, branchName interface{}) *gomock.Call { 167 | mr.mock.ctrl.T.Helper() 168 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLsRemoteHeadOid", reflect.TypeOf((*MockConnection)(nil).GetLsRemoteHeadOid), ctx, url, branchName) 169 | } 170 | 171 | // GetMergedBranchNames mocks base method. 172 | func (m *MockConnection) GetMergedBranchNames(ctx context.Context, remoteName, branchName string) (string, error) { 173 | m.ctrl.T.Helper() 174 | ret := m.ctrl.Call(m, "GetMergedBranchNames", ctx, remoteName, branchName) 175 | ret0, _ := ret[0].(string) 176 | ret1, _ := ret[1].(error) 177 | return ret0, ret1 178 | } 179 | 180 | // GetMergedBranchNames indicates an expected call of GetMergedBranchNames. 181 | func (mr *MockConnectionMockRecorder) GetMergedBranchNames(ctx, remoteName, branchName interface{}) *gomock.Call { 182 | mr.mock.ctrl.T.Helper() 183 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMergedBranchNames", reflect.TypeOf((*MockConnection)(nil).GetMergedBranchNames), ctx, remoteName, branchName) 184 | } 185 | 186 | // GetPullRequests mocks base method. 187 | func (m *MockConnection) GetPullRequests(ctx context.Context, hostname, orgs, repos, queryHashes string) (string, error) { 188 | m.ctrl.T.Helper() 189 | ret := m.ctrl.Call(m, "GetPullRequests", ctx, hostname, orgs, repos, queryHashes) 190 | ret0, _ := ret[0].(string) 191 | ret1, _ := ret[1].(error) 192 | return ret0, ret1 193 | } 194 | 195 | // GetPullRequests indicates an expected call of GetPullRequests. 196 | func (mr *MockConnectionMockRecorder) GetPullRequests(ctx, hostname, orgs, repos, queryHashes interface{}) *gomock.Call { 197 | mr.mock.ctrl.T.Helper() 198 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullRequests", reflect.TypeOf((*MockConnection)(nil).GetPullRequests), ctx, hostname, orgs, repos, queryHashes) 199 | } 200 | 201 | // GetRemoteHeadOid mocks base method. 202 | func (m *MockConnection) GetRemoteHeadOid(ctx context.Context, remoteName, branchName string) (string, error) { 203 | m.ctrl.T.Helper() 204 | ret := m.ctrl.Call(m, "GetRemoteHeadOid", ctx, remoteName, branchName) 205 | ret0, _ := ret[0].(string) 206 | ret1, _ := ret[1].(error) 207 | return ret0, ret1 208 | } 209 | 210 | // GetRemoteHeadOid indicates an expected call of GetRemoteHeadOid. 211 | func (mr *MockConnectionMockRecorder) GetRemoteHeadOid(ctx, remoteName, branchName interface{}) *gomock.Call { 212 | mr.mock.ctrl.T.Helper() 213 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRemoteHeadOid", reflect.TypeOf((*MockConnection)(nil).GetRemoteHeadOid), ctx, remoteName, branchName) 214 | } 215 | 216 | // GetRemoteNames mocks base method. 217 | func (m *MockConnection) GetRemoteNames(ctx context.Context) (string, error) { 218 | m.ctrl.T.Helper() 219 | ret := m.ctrl.Call(m, "GetRemoteNames", ctx) 220 | ret0, _ := ret[0].(string) 221 | ret1, _ := ret[1].(error) 222 | return ret0, ret1 223 | } 224 | 225 | // GetRemoteNames indicates an expected call of GetRemoteNames. 226 | func (mr *MockConnectionMockRecorder) GetRemoteNames(ctx interface{}) *gomock.Call { 227 | mr.mock.ctrl.T.Helper() 228 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRemoteNames", reflect.TypeOf((*MockConnection)(nil).GetRemoteNames), ctx) 229 | } 230 | 231 | // GetRepoNames mocks base method. 232 | func (m *MockConnection) GetRepoNames(ctx context.Context, hostname, repoName string) (string, error) { 233 | m.ctrl.T.Helper() 234 | ret := m.ctrl.Call(m, "GetRepoNames", ctx, hostname, repoName) 235 | ret0, _ := ret[0].(string) 236 | ret1, _ := ret[1].(error) 237 | return ret0, ret1 238 | } 239 | 240 | // GetRepoNames indicates an expected call of GetRepoNames. 241 | func (mr *MockConnectionMockRecorder) GetRepoNames(ctx, hostname, repoName interface{}) *gomock.Call { 242 | mr.mock.ctrl.T.Helper() 243 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRepoNames", reflect.TypeOf((*MockConnection)(nil).GetRepoNames), ctx, hostname, repoName) 244 | } 245 | 246 | // GetSshConfig mocks base method. 247 | func (m *MockConnection) GetSshConfig(ctx context.Context, name string) (string, error) { 248 | m.ctrl.T.Helper() 249 | ret := m.ctrl.Call(m, "GetSshConfig", ctx, name) 250 | ret0, _ := ret[0].(string) 251 | ret1, _ := ret[1].(error) 252 | return ret0, ret1 253 | } 254 | 255 | // GetSshConfig indicates an expected call of GetSshConfig. 256 | func (mr *MockConnectionMockRecorder) GetSshConfig(ctx, name interface{}) *gomock.Call { 257 | mr.mock.ctrl.T.Helper() 258 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSshConfig", reflect.TypeOf((*MockConnection)(nil).GetSshConfig), ctx, name) 259 | } 260 | 261 | // GetUncommittedChanges mocks base method. 262 | func (m *MockConnection) GetUncommittedChanges(ctx context.Context) (string, error) { 263 | m.ctrl.T.Helper() 264 | ret := m.ctrl.Call(m, "GetUncommittedChanges", ctx) 265 | ret0, _ := ret[0].(string) 266 | ret1, _ := ret[1].(error) 267 | return ret0, ret1 268 | } 269 | 270 | // GetUncommittedChanges indicates an expected call of GetUncommittedChanges. 271 | func (mr *MockConnectionMockRecorder) GetUncommittedChanges(ctx interface{}) *gomock.Call { 272 | mr.mock.ctrl.T.Helper() 273 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUncommittedChanges", reflect.TypeOf((*MockConnection)(nil).GetUncommittedChanges), ctx) 274 | } 275 | 276 | // RemoveConfig mocks base method. 277 | func (m *MockConnection) RemoveConfig(ctx context.Context, key string) (string, error) { 278 | m.ctrl.T.Helper() 279 | ret := m.ctrl.Call(m, "RemoveConfig", ctx, key) 280 | ret0, _ := ret[0].(string) 281 | ret1, _ := ret[1].(error) 282 | return ret0, ret1 283 | } 284 | 285 | // RemoveConfig indicates an expected call of RemoveConfig. 286 | func (mr *MockConnectionMockRecorder) RemoveConfig(ctx, key interface{}) *gomock.Call { 287 | mr.mock.ctrl.T.Helper() 288 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveConfig", reflect.TypeOf((*MockConnection)(nil).RemoveConfig), ctx, key) 289 | } 290 | -------------------------------------------------------------------------------- /shared/branch.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import "regexp" 4 | 5 | type ( 6 | BranchState int 7 | 8 | Branch struct { 9 | Head bool 10 | Name string 11 | IsDefault bool 12 | IsMerged bool 13 | IsProtected bool 14 | HasTrackedChanges bool 15 | RemoteHeadOid string 16 | Commits []string 17 | PullRequests []PullRequest 18 | State BranchState 19 | } 20 | ) 21 | 22 | const ( 23 | Unknown BranchState = iota 24 | NotDeletable 25 | Deletable 26 | Deleted 27 | ) 28 | 29 | var detachedBranchNameRegex = regexp.MustCompile(`^\(.+\)`) 30 | 31 | func (b Branch) IsDetached() bool { 32 | return detachedBranchNameRegex.MatchString(b.Name) 33 | } 34 | -------------------------------------------------------------------------------- /shared/connection.go: -------------------------------------------------------------------------------- 1 | //go:generate mockgen -source=connection.go -package=mocks -destination=../mocks/poi_mock.go 2 | package shared 3 | 4 | import "context" 5 | 6 | type Connection interface { 7 | CheckRepos(ctx context.Context, hostname string, repoNames []string) error 8 | GetRemoteNames(ctx context.Context) (string, error) 9 | GetSshConfig(ctx context.Context, name string) (string, error) 10 | GetRepoNames(ctx context.Context, hostname string, repoName string) (string, error) 11 | GetBranchNames(ctx context.Context) (string, error) 12 | GetMergedBranchNames(ctx context.Context, remoteName string, branchName string) (string, error) 13 | GetRemoteHeadOid(ctx context.Context, remoteName string, branchName string) (string, error) 14 | GetLsRemoteHeadOid(ctx context.Context, url string, branchName string) (string, error) 15 | GetLog(ctx context.Context, branchName string) (string, error) 16 | GetAssociatedRefNames(ctx context.Context, oid string) (string, error) 17 | GetPullRequests(ctx context.Context, hostname string, orgs string, repos string, queryHashes string) (string, error) 18 | GetUncommittedChanges(ctx context.Context) (string, error) 19 | GetConfig(ctx context.Context, key string) (string, error) 20 | AddConfig(ctx context.Context, key string, value string) (string, error) 21 | RemoveConfig(ctx context.Context, key string) (string, error) 22 | CheckoutBranch(ctx context.Context, branchName string) (string, error) 23 | DeleteBranches(ctx context.Context, branchNames []string) (string, error) 24 | } 25 | -------------------------------------------------------------------------------- /shared/pull_request.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | type ( 4 | PullRequestState int 5 | 6 | PullRequest struct { 7 | Name string 8 | State PullRequestState 9 | IsDraft bool 10 | Number int 11 | Commits []string 12 | Url string 13 | Author string 14 | } 15 | ) 16 | 17 | const ( 18 | Closed PullRequestState = iota 19 | Merged 20 | Open 21 | ) 22 | -------------------------------------------------------------------------------- /shared/querygen.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func GetQueryOrgs(repoNames []string) string { 9 | var repos strings.Builder 10 | for _, name := range repoNames { 11 | repos.WriteString(fmt.Sprintf("org:%s ", strings.Split(name, "/")[0])) 12 | } 13 | return strings.TrimSpace(repos.String()) 14 | } 15 | 16 | func GetQueryRepos(repoNames []string) string { 17 | var repos strings.Builder 18 | for _, name := range repoNames { 19 | repos.WriteString(fmt.Sprintf("repo:%s ", name)) 20 | } 21 | return strings.TrimSpace(repos.String()) 22 | } 23 | 24 | func GetQueryHashes(branches []Branch) []string { 25 | results := []string{} 26 | 27 | var hashes strings.Builder 28 | for i, branch := range branches { 29 | if branch.RemoteHeadOid == "" && len(branch.Commits) == 0 { 30 | continue 31 | } 32 | 33 | separator := " " 34 | if i == len(branches)-1 { 35 | separator = "" 36 | } 37 | oid := "" 38 | if branch.RemoteHeadOid == "" { 39 | oid = branch.Commits[len(branch.Commits)-1] 40 | } else { 41 | oid = branch.RemoteHeadOid 42 | } 43 | hash := fmt.Sprintf("hash:%s%s", oid, separator) 44 | 45 | // https://docs.github.com/en/rest/reference/search#limitations-on-query-length 46 | if len(hashes.String())+len(hash) > 256 { 47 | results = append(results, hashes.String()) 48 | hashes.Reset() 49 | } 50 | 51 | hashes.WriteString(hash) 52 | } 53 | if len(hashes.String()) > 0 { 54 | results = append(results, hashes.String()) 55 | } 56 | 57 | return results 58 | } 59 | -------------------------------------------------------------------------------- /shared/querygen_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_GetQueryOrgs(t *testing.T) { 10 | assert.Equal(t, 11 | "org:parent-owner org:owner", 12 | GetQueryOrgs([]string{"parent-owner/repo", "owner/repo"}), 13 | ) 14 | } 15 | 16 | func Test_GetQueryRepos(t *testing.T) { 17 | assert.Equal(t, 18 | "repo:parent-owner/repo repo:owner/repo", 19 | GetQueryRepos([]string{"parent-owner/repo", "owner/repo"}), 20 | ) 21 | } 22 | 23 | func Test_GetQueryHashesWithCommitOid(t *testing.T) { 24 | assert.Equal(t, 25 | []string{ 26 | "hash:356a192b7913b04c54574d18c28d46e6395428ab " + 27 | "hash:08a2aaaadff191eb76974b9b3d8b71f202c0156e " + 28 | "hash:77de68daecd823babbb58edb1c8e14d7106e83bb " + 29 | "hash:1b6453892473a467d07372d45eb05abc2031647a " + 30 | "hash:ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4 ", 31 | "hash:c1dfd96eea8cc2b62785275bca38ac261256e278", 32 | }, 33 | GetQueryHashes([]Branch{ 34 | {Head: false, Name: "main", IsMerged: false, IsProtected: false, 35 | RemoteHeadOid: "", 36 | Commits: []string{}, 37 | PullRequests: []PullRequest{}, State: Unknown, 38 | }, 39 | {Head: true, Name: "issue1", IsMerged: false, IsProtected: false, 40 | RemoteHeadOid: "", 41 | Commits: []string{ 42 | "356a192b7913b04c54574d18c28d46e6395428ab", 43 | }, 44 | PullRequests: []PullRequest{}, State: Unknown, 45 | }, 46 | {Head: false, Name: "issue2", IsMerged: false, IsProtected: false, 47 | RemoteHeadOid: "", 48 | Commits: []string{ 49 | "da4b9237bacccdf19c0760cab7aec4a8359010b0", 50 | "08a2aaaadff191eb76974b9b3d8b71f202c0156e", 51 | }, 52 | PullRequests: []PullRequest{}, State: Unknown, 53 | }, 54 | {Head: false, Name: "issue3", IsMerged: false, IsProtected: false, 55 | RemoteHeadOid: "", 56 | Commits: []string{ 57 | "77de68daecd823babbb58edb1c8e14d7106e83bb", 58 | }, 59 | PullRequests: []PullRequest{}, State: Unknown, 60 | }, 61 | {Head: false, Name: "issue4", IsMerged: false, IsProtected: false, 62 | RemoteHeadOid: "", 63 | Commits: []string{ 64 | "1b6453892473a467d07372d45eb05abc2031647a", 65 | }, 66 | PullRequests: []PullRequest{}, State: Unknown, 67 | }, 68 | {Head: false, Name: "issue5", IsMerged: false, IsProtected: false, 69 | RemoteHeadOid: "", 70 | Commits: []string{ 71 | "ac3478d69a3c81fa62e60f5c3696165a4e5e6ac4", 72 | }, 73 | PullRequests: []PullRequest{}, State: Unknown, 74 | }, 75 | {Head: false, Name: "issue6", IsMerged: false, IsProtected: false, 76 | RemoteHeadOid: "", 77 | Commits: []string{ 78 | "c1dfd96eea8cc2b62785275bca38ac261256e278", 79 | }, 80 | PullRequests: []PullRequest{}, State: Unknown, 81 | }, 82 | }), 83 | ) 84 | } 85 | 86 | func Test_GetQueryHashesWithRemoteOid(t *testing.T) { 87 | assert.Equal(t, 88 | []string{ 89 | "hash:356a192b7913b04c54574d18c28d46e6395428ab", 90 | }, 91 | GetQueryHashes([]Branch{ 92 | {Head: true, Name: "issue1", IsMerged: false, IsProtected: false, 93 | RemoteHeadOid: "356a192b7913b04c54574d18c28d46e6395428ab", 94 | Commits: []string{ 95 | "da4b9237bacccdf19c0760cab7aec4a8359010b0", 96 | "08a2aaaadff191eb76974b9b3d8b71f202c0156e", 97 | }, 98 | PullRequests: []PullRequest{}, State: Unknown, 99 | }, 100 | }), 101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /shared/remote.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | type ( 11 | Remote struct { 12 | Name string 13 | Hostname string 14 | RepoName string 15 | } 16 | ) 17 | 18 | var ( 19 | hasSchemePattern = regexp.MustCompile("^[^:]+://") 20 | scpLikeURLPattern = regexp.MustCompile("^([^@]+@)?([^:]+):(/?.+)$") 21 | ) 22 | 23 | // NewRemote parses the result of `git remote -v` and returns a Remote struct. 24 | // 25 | // acceptable url formats: 26 | // 27 | // ssh://[user@]host.xz[:port]/path/to/repo.git/ 28 | // git://host.xz[:port]/path/to/repo.git/ 29 | // http[s]://host.xz[:port]/path/to/repo.git/ 30 | // ftp[s]://host.xz[:port]/path/to/repo.git/ 31 | // 32 | // An alternative scp-like syntax may also be used with the ssh protocol: 33 | // 34 | // [user@]host.xz:path/to/repo.git/ 35 | // 36 | // ref. http://git-scm.com/docs/git-fetch#_git_urls 37 | // the code is heavily inspired by https://github.com/x-motemen/ghq/blob/7163e61e2309a039241ad40b4a25bea35671ea6f/url.go 38 | func NewRemote(remoteConfig string) Remote { 39 | splitConfig := strings.Fields(remoteConfig) 40 | if len(splitConfig) != 3 { 41 | return Remote{} 42 | } 43 | 44 | ref := splitConfig[1] 45 | if !hasSchemePattern.MatchString(ref) { 46 | if scpLikeURLPattern.MatchString(ref) { 47 | matched := scpLikeURLPattern.FindStringSubmatch(ref) 48 | user := matched[1] 49 | host := matched[2] 50 | path := matched[3] 51 | ref = fmt.Sprintf("ssh://%s%s/%s", user, host, strings.TrimPrefix(path, "/")) 52 | } 53 | } 54 | u, err := url.Parse(ref) 55 | if err != nil { 56 | return Remote{} 57 | } 58 | 59 | repo := u.Path 60 | repo = strings.TrimPrefix(repo, "/") 61 | repo = strings.TrimSuffix(repo, ".git") 62 | 63 | return Remote{ 64 | Name: splitConfig[0], 65 | Hostname: u.Host, 66 | RepoName: repo, 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /shared/remote_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_CreateRemoteWithScpLikeUrl(t *testing.T) { 10 | assert.Equal(t, 11 | Remote{ 12 | Name: "origin", 13 | Hostname: "github.com", 14 | RepoName: "org/repo", 15 | }, 16 | NewRemote("origin git@github.com:org/repo (fetch)"), 17 | ) 18 | } 19 | 20 | func Test_CreateRemoteWithScpLikeUrlAndCustomUserinfo(t *testing.T) { 21 | assert.Equal(t, 22 | Remote{ 23 | Name: "origin", 24 | Hostname: "github.com", 25 | RepoName: "org/repo", 26 | }, 27 | NewRemote("origin git0-._~@github.com:org/repo (fetch)"), 28 | ) 29 | } 30 | 31 | func Test_CreateRemoteWithSshUrl(t *testing.T) { 32 | assert.Equal(t, 33 | Remote{ 34 | Name: "origin", 35 | Hostname: "github.com", 36 | RepoName: "org/repo", 37 | }, 38 | NewRemote("origin ssh://git@github.com/org/repo.git (fetch)"), 39 | ) 40 | } 41 | 42 | func Test_CreateRemoteWithScpLikeUrlWithoutUserinfo(t *testing.T) { 43 | assert.Equal(t, 44 | Remote{ 45 | Name: "origin", 46 | Hostname: "github.com", 47 | RepoName: "org/repo", 48 | }, 49 | NewRemote("origin github.com:org/repo.git (fetch)"), 50 | ) 51 | } 52 | 53 | func Test_CreateRemoteWithHttps(t *testing.T) { 54 | assert.Equal(t, 55 | Remote{ 56 | Name: "origin", 57 | Hostname: "github.com", 58 | RepoName: "org/repo", 59 | }, 60 | NewRemote("origin https://github.com/org/repo.git (fetch)"), 61 | ) 62 | } 63 | 64 | // https://github.com/seachicken/gh-poi/issues/39 65 | func Test_CreateRemoteWithCustomHostname(t *testing.T) { 66 | assert.Equal(t, 67 | Remote{ 68 | Name: "origin", 69 | Hostname: "github.com-work", 70 | RepoName: "org/repo", 71 | }, 72 | NewRemote("origin git@github.com-work:org/repo.git (fetch)"), 73 | ) 74 | } 75 | --------------------------------------------------------------------------------