├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── lint.yml │ └── build.yml ├── .golangci.yml ├── .gitignore ├── internal └── ui │ ├── msgs.go │ ├── styles.go │ ├── keys.go │ ├── commands.go │ ├── item.go │ └── app.go ├── LICENSE.md ├── go.mod ├── goreleaser.yml ├── cmd └── fork-cleaner │ └── main.go ├── README.md ├── fork-cleaner.go └── go.sum /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [caarlos0] 2 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | .env 3 | ./fork-cleaner 4 | dist 5 | bin 6 | coverage.out 7 | fork-cleaner.log 8 | 9 | /.idea/ 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "08:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /internal/ui/msgs.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import forkcleaner "github.com/caarlos0/fork-cleaner/v2" 4 | 5 | type errMsg struct{ error } 6 | 7 | func (e errMsg) Error() string { return e.error.Error() } 8 | 9 | type getRepoListMsg struct{} 10 | 11 | type gotRepoListMsg struct { 12 | repos []*forkcleaner.RepositoryWithDetails 13 | } 14 | 15 | type reposDeletedMsg struct{} 16 | 17 | type requestDeleteSelectedReposMsg struct{} 18 | -------------------------------------------------------------------------------- /internal/ui/styles.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var ( 6 | errorColor = lipgloss.AdaptiveColor{ 7 | Light: "#e94560", 8 | Dark: "#f05945", 9 | } 10 | listStyle = lipgloss.NewStyle().Margin(2) 11 | detailsStyle = lipgloss.NewStyle().PaddingLeft(2) 12 | 13 | errorStyle = lipgloss.NewStyle().Foreground(errorColor) 14 | ) 15 | 16 | const ( 17 | iconSelected = "●" 18 | iconNotSelected = "○" 19 | separator = " • " 20 | ) 21 | -------------------------------------------------------------------------------- /internal/ui/keys.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | var ( 6 | keySelectAll = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all")) 7 | keySelectNone = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "select none")) 8 | keySelectToggle = key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle selected item")) 9 | keyDeletedSelected = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete selected forks")) 10 | ) 11 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - main 8 | pull_request: 9 | jobs: 10 | golangci: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-go@v4 16 | with: 17 | go-version: stable 18 | - name: golangci-lint 19 | uses: golangci/golangci-lint-action@v3 20 | with: 21 | skip-go-installation: true 22 | 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Carlos Alexandro Becker 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 | -------------------------------------------------------------------------------- /internal/ui/commands.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "time" 9 | 10 | forkcleaner "github.com/caarlos0/fork-cleaner/v2" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/google/go-github/v50/github" 13 | ) 14 | 15 | func requestDeleteReposCmd() tea.Msg { 16 | return requestDeleteSelectedReposMsg{} 17 | } 18 | 19 | func deleteReposCmd(client *github.Client, repos []*forkcleaner.RepositoryWithDetails) tea.Cmd { 20 | return func() tea.Msg { 21 | var names []string 22 | for _, r := range repos { 23 | names = append(names, r.Name) 24 | } 25 | log.Println("deleteReposCmd", strings.Join(names, ", ")) 26 | if err := forkcleaner.Delete(context.Background(), client, repos); err != nil { 27 | return errMsg{err} 28 | } 29 | return reposDeletedMsg{} 30 | } 31 | } 32 | 33 | func enqueueGetReposCmd() tea.Msg { 34 | return getRepoListMsg{} 35 | } 36 | 37 | func getReposCmd(client *github.Client, login string, skipUpstream bool) tea.Cmd { 38 | limits, _, err := client.RateLimits(context.Background()) 39 | if err != nil { 40 | return func() tea.Msg { 41 | return errMsg{err} 42 | } 43 | } 44 | log.Println("RateLimits: ", limits) 45 | if limits.Core.Remaining < 1 { 46 | return func() tea.Msg { 47 | return errMsg{ 48 | fmt.Errorf("Rate limit exceeded. Remaining: %d, Time till reset: %v", 49 | limits.Core.Remaining, time.Since(limits.Core.Reset.Time)), 50 | } 51 | } 52 | } 53 | 54 | return func() tea.Msg { 55 | log.Println("getReposCmd") 56 | repos, err := forkcleaner.FindAllForks(context.Background(), client, login, skipUpstream) 57 | if err != nil { 58 | return errMsg{err} 59 | } 60 | return gotRepoListMsg{repos} 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/caarlos0/fork-cleaner/v2 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/caarlos0/timea.go v1.2.0 7 | github.com/charmbracelet/bubbles v0.21.0 8 | github.com/charmbracelet/bubbletea v1.3.10 9 | github.com/charmbracelet/lipgloss v1.1.0 10 | github.com/google/go-github/v50 v50.2.0 11 | github.com/urfave/cli/v2 v2.27.7 12 | golang.org/x/oauth2 v0.34.0 13 | ) 14 | 15 | require ( 16 | github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect 17 | github.com/atotto/clipboard v0.1.4 // indirect 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 20 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 21 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 22 | github.com/charmbracelet/x/term v0.2.1 // indirect 23 | github.com/cloudflare/circl v1.6.1 // indirect 24 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 25 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 26 | github.com/google/go-querystring v1.1.0 // indirect 27 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 28 | github.com/mattn/go-isatty v0.0.20 // indirect 29 | github.com/mattn/go-localereader v0.0.1 // indirect 30 | github.com/mattn/go-runewidth v0.0.16 // indirect 31 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 32 | github.com/muesli/cancelreader v0.2.2 // indirect 33 | github.com/muesli/termenv v0.16.0 // indirect 34 | github.com/rivo/uniseg v0.4.7 // indirect 35 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 36 | github.com/sahilm/fuzzy v0.1.1 // indirect 37 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 38 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 39 | golang.org/x/crypto v0.45.0 // indirect 40 | golang.org/x/sys v0.38.0 // indirect 41 | golang.org/x/text v0.31.0 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | tags: 8 | - "v*" 9 | pull_request: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | - uses: actions/setup-go@v4 19 | with: 20 | go-version: stable 21 | cache: true 22 | - run: go mod tidy 23 | - run: go test -v -failfast -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.out ./... 24 | - uses: codecov/codecov-action@v1 25 | with: 26 | token: ${{ secrets.CODECOV_TOKEN }} 27 | file: ./coverage.txt 28 | - if: success() && startsWith(github.ref, 'refs/tags/') 29 | run: | 30 | sudo apt-get update 31 | sudo apt-get -yq --no-install-suggests --no-install-recommends install snapcraft 32 | snapcraft login --with <(echo "${{ secrets.SNAPCRAFT_LOGIN }}") 33 | - uses: goreleaser/goreleaser-action@v4 34 | if: success() && startsWith(github.ref, 'refs/tags/') 35 | with: 36 | distribution: goreleaser-pro 37 | version: nightly 38 | args: release --rm-dist 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 41 | FURY_TOKEN: ${{ secrets.FURY_TOKEN }} 42 | GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 43 | dependabot: 44 | needs: [build] 45 | runs-on: ubuntu-latest 46 | permissions: 47 | pull-requests: write 48 | contents: write 49 | if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'}} 50 | steps: 51 | - id: metadata 52 | uses: dependabot/fetch-metadata@v2 53 | with: 54 | github-token: "${{ secrets.GITHUB_TOKEN }}" 55 | - run: | 56 | gh pr review --approve "$PR_URL" 57 | gh pr merge --squash --auto "$PR_URL" 58 | env: 59 | PR_URL: ${{github.event.pull_request.html_url}} 60 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 61 | -------------------------------------------------------------------------------- /goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json 2 | # vim: set ts=2 sw=2 tw=0 fo=jcroql 3 | 4 | variables: 5 | homepage: https://github.com/caarlos0/fork-cleaner 6 | repository: https://github.com/caarlos0/fork-cleaner 7 | description: Cleans up old and inactive forks on your github account. 8 | 9 | includes: 10 | - from_url: 11 | url: https://raw.githubusercontent.com/caarlos0/goreleaserfiles/main/release.yml 12 | - from_url: 13 | url: https://raw.githubusercontent.com/caarlos0/goreleaserfiles/main/package.yml 14 | 15 | before: 16 | hooks: 17 | - go mod tidy 18 | 19 | furies: 20 | - account: caarlos0 21 | 22 | # gomod: 23 | # proxy: true 24 | 25 | builds: 26 | - env: 27 | - CGO_ENABLED=0 28 | main: ./cmd/fork-cleaner/ 29 | goos: 30 | - linux 31 | - darwin 32 | - windows 33 | goarch: 34 | - amd64 35 | - arm64 36 | mod_timestamp: "{{ .CommitTimestamp }}" 37 | flags: 38 | - -trimpath 39 | ldflags: 40 | - -s -w -X main.version={{ .Version }} -X main.commit={{ .Commit }} -X main.date={{ .CommitDate }} -X main.builtBy=goreleaser 41 | 42 | universal_binaries: 43 | - replace: true 44 | 45 | archives: 46 | - format: tar.gz 47 | format_overrides: 48 | - format: zip 49 | goos: windows 50 | 51 | snapcrafts: 52 | - publish: true 53 | summary: "{{ .Var.description }}" 54 | description: "{{ .Var.description }}" 55 | grade: stable 56 | apps: 57 | fork-cleaner: 58 | plugs: ["network"] 59 | command: fork-cleaner 60 | 61 | nix: 62 | - name: fork-cleaner 63 | description: "{{ .Var.description }}" 64 | homepage: "{{ .Var.homepage }}" 65 | license: mit 66 | repository: 67 | owner: caarlos0 68 | name: nur 69 | 70 | winget: 71 | - name: fork-cleaner 72 | short_description: "{{ .Var.description }}" 73 | homepage: "{{ .Var.homepage }}" 74 | license: MIT 75 | publisher: caarlos0 76 | repository: 77 | owner: caarlos0 78 | name: winget-pkgs 79 | branch: "{{.ProjectName}}-{{.Version}}" 80 | pull_request: 81 | enabled: true 82 | draft: true 83 | base: 84 | owner: microsoft 85 | name: winget-pkgs 86 | branch: master 87 | -------------------------------------------------------------------------------- /cmd/fork-cleaner/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/caarlos0/fork-cleaner/v2/internal/ui" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/google/go-github/v50/github" 12 | "github.com/urfave/cli/v2" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | var version = "main" 17 | 18 | func main() { 19 | app := cli.NewApp() 20 | app.Name = "fork-cleaner" 21 | app.Version = version 22 | app.Authors = []*cli.Author{{ 23 | Name: "Carlos Alexandro Becker", 24 | Email: "carlos@becker.software", 25 | }} 26 | app.Usage = "Delete old, unused forks" 27 | app.Flags = []cli.Flag{ 28 | &cli.StringFlag{ 29 | EnvVars: []string{"GITHUB_TOKEN"}, 30 | Name: "token", 31 | Usage: "Your GitHub token", 32 | Aliases: []string{"t"}, 33 | }, 34 | &cli.StringFlag{ 35 | EnvVars: []string{"GITHUB_URL"}, 36 | Name: "github-url", 37 | Usage: "Base GitHub URL", 38 | Value: "https://api.github.com/", 39 | Aliases: []string{"g"}, 40 | }, 41 | &cli.StringFlag{ 42 | Name: "user", 43 | Usage: "GitHub username or organization name. Defaults to current user.", 44 | Aliases: []string{"u"}, 45 | }, 46 | &cli.BoolFlag{ 47 | Name: "skip-upstream-check", 48 | Usage: "Skip checking and pulling details from the parent/upstream repository", 49 | Aliases: []string{"skip-upstream"}, 50 | Value: false, 51 | }, 52 | } 53 | 54 | app.Action = func(c *cli.Context) error { 55 | log.SetFlags(0) 56 | f, err := tea.LogToFile(filepath.Join(os.TempDir(), "fork-cleaner.log"), "") 57 | if err != nil { 58 | return cli.Exit(err.Error(), 1) 59 | } 60 | defer func() { _ = f.Close() }() 61 | 62 | token := c.String("token") 63 | ghurl := c.String("github-url") 64 | login := c.String("user") 65 | skipUpstream := c.Bool("skip-upstream-check") 66 | 67 | ctx := context.Background() 68 | ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 69 | tc := oauth2.NewClient(ctx, ts) 70 | client, err := github.NewEnterpriseClient(ghurl, ghurl, tc) 71 | if err != nil { 72 | return cli.Exit(err.Error(), 1) 73 | } 74 | 75 | if token == "" { 76 | return cli.Exit("missing github token", 1) 77 | } 78 | 79 | p := tea.NewProgram(ui.NewAppModel(client, login, skipUpstream), tea.WithAltScreen()) 80 | if _, err = p.Run(); err != nil { 81 | return cli.Exit(err.Error(), 1) 82 | } 83 | return nil 84 | } 85 | 86 | if err := app.Run(os.Args); err != nil { 87 | log.Fatalln(err) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/ui/item.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | forkcleaner "github.com/caarlos0/fork-cleaner/v2" 9 | timeago "github.com/caarlos0/timea.go" 10 | "github.com/charmbracelet/bubbles/list" 11 | ) 12 | 13 | type item struct { 14 | repo *forkcleaner.RepositoryWithDetails 15 | selected bool 16 | } 17 | 18 | func (i item) Title() string { 19 | var forked string 20 | if i.repo.ParentName != "" { 21 | forked = fmt.Sprintf(" (forked from %s)", i.repo.ParentName) 22 | } 23 | if i.selected { 24 | return iconSelected + " " + i.repo.Name + forked 25 | } 26 | return iconNotSelected + " " + i.repo.Name + forked 27 | } 28 | 29 | func (i item) Description() string { 30 | repo := i.repo 31 | var details []string 32 | if repo.ParentDeleted { 33 | details = append(details, "parent was deleted") 34 | } 35 | if repo.ParentDMCATakeDown { 36 | details = append(details, "parent was taken down by DMCA") 37 | } 38 | if repo.Private { 39 | details = append(details, "is private") 40 | } 41 | if repo.CommitsAhead > 0 { 42 | details = append(details, fmt.Sprintf("%d commit%s ahead", repo.CommitsAhead, maybePlural(repo.CommitsAhead))) 43 | } 44 | if repo.Forks > 0 { 45 | details = append(details, fmt.Sprintf("has %d fork%s", repo.Forks, maybePlural(repo.Forks))) 46 | } 47 | if repo.Stars > 0 { 48 | details = append(details, fmt.Sprintf("has %d star%s", repo.Stars, maybePlural(repo.Stars))) 49 | } 50 | if repo.OpenPRs > 0 { 51 | details = append(details, fmt.Sprintf("has %d open PR%s to upstream", repo.OpenPRs, maybePlural(repo.OpenPRs))) 52 | } 53 | if time.Now().Add(-30 * 24 * time.Hour).Before(repo.LastUpdate) { 54 | details = append(details, fmt.Sprintf("recently updated (%s)", timeago.Of(repo.LastUpdate))) 55 | } 56 | 57 | return detailsStyle.Render(strings.Join(details, separator)) 58 | } 59 | 60 | func maybePlural(n int) string { 61 | if n == 1 { 62 | return "" 63 | } 64 | return "s" 65 | } 66 | 67 | func (i item) FilterValue() string { return " " + i.repo.Name } 68 | 69 | func splitBySelection(items []list.Item) ([]*forkcleaner.RepositoryWithDetails, []*forkcleaner.RepositoryWithDetails) { 70 | var selected, unselected []*forkcleaner.RepositoryWithDetails 71 | for _, it := range items { 72 | item := it.(item) 73 | if item.selected { 74 | selected = append(selected, item.repo) 75 | } else { 76 | unselected = append(unselected, item.repo) 77 | } 78 | } 79 | return selected, unselected 80 | } 81 | 82 | func reposToItems(repos []*forkcleaner.RepositoryWithDetails) []list.Item { 83 | var items = make([]list.Item, 0, len(repos)) 84 | for _, repo := range repos { 85 | items = append(items, item{ 86 | repo: repo, 87 | }) 88 | } 89 | return items 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fork-cleaner 2 | 3 | [![Release](https://img.shields.io/github/release/caarlos0/fork-cleaner.svg?style=for-the-badge)](https://github.com/caarlos0/fork-cleaner/releases/latest) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=for-the-badge)](LICENSE.md) 5 | [![Build Status](https://img.shields.io/github/actions/workflow/status/caarlos0/fork-cleaner/build.yml?style=for-the-badge)](https://github.com/caarlos0/fork-cleaner/actions?workflow=build) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/caarlos0/fork-cleaner?style=for-the-badge)](https://goreportcard.com/report/github.com/caarlos0/fork-cleaner) 7 | [![Godoc](http://img.shields.io/badge/godoc-reference-5272B4.svg?style=for-the-badge)](https://pkg.go.dev/github.com/caarlos0/fork-cleaner) 8 | [![Powered By: GoReleaser](https://img.shields.io/badge/powered%20by-goreleaser-green.svg?style=for-the-badge)](https://github.com/goreleaser) 9 | 10 | Quickly clean up old and inactive forks on your GitHub account. 11 | 12 | ![](https://user-images.githubusercontent.com/245435/104655305-4a843f80-569c-11eb-8cd5-7f55b8104375.gif) 13 | 14 | ## Installation 15 | 16 | ### Homebrew 17 | 18 | ```sh 19 | brew install caarlos0/tap/fork-cleaner 20 | ``` 21 | 22 | ### snap 23 | 24 | ```sh 25 | snap install fork-cleaner 26 | ``` 27 | 28 | ### apt 29 | 30 | ```sh 31 | echo 'deb [trusted=yes] https://repo.caarlos0.dev/apt/ /' | sudo tee /etc/apt/sources.list.d/caarlos0.list 32 | sudo apt update 33 | sudo apt install fork-cleaner 34 | ``` 35 | 36 | ### yum 37 | 38 | ```sh 39 | echo '[caarlos0] 40 | name=caarlos0 41 | baseurl=https://repo.caarlos0.dev/yum/ 42 | enabled=1 43 | gpgcheck=0' | sudo tee /etc/yum.repos.d/caarlos0.repo 44 | sudo yum install fork-cleaner 45 | ``` 46 | 47 | ### deb/rpm/apk 48 | 49 | Download the `.apk`, `.deb` or `.rpm` from the [latest release](https://github.com/caarlos0/fork-cleaner/releases/latest) and install with the appropriate commands. 50 | 51 | ### Manually 52 | 53 | Download the binaries from the [latest release](https://github.com/caarlos0/fork-cleaner/releases/latest) or clone the repository and build from source. 54 | 55 | ## Usage 56 | 57 | You'll need to [create a personal access token](https://github.com/settings/tokens/new?scopes=repo,delete_repo&description=fork-cleaner) with `repo` and `delete_repo` 58 | permissions. You'll need to pass this token to `fork-cleaner` with the `--token` flag. 59 | 60 | ```sh 61 | fork-cleaner --token "" 62 | ``` 63 | 64 | `fork-cleaner` will load your forked repositories, displaying the oldest first. This can take a little while as `fork-cleaner` will iterate over the page of forks and check the upstream repository's status (e.g. checking for active PRs). 65 | 66 | ## Troubleshooting 67 | 68 | ### Taking forever to load? 69 | 70 | The app hits various endpoints in order to collect information on the upstream repository, this can take a while if you have a lot of forks. Setting `-skip-upstream=true` will skip checking commits, issues, PRs, etc on each upstream repository, potentially alleviating this issue. 71 | 72 | ### I've hit the rate limit. 73 | 74 | You can check your current limits by calling GitHub's API: 75 | 76 | ```sh 77 | curl -L \ 78 | -H "Accept: application/vnd.github+json" \ 79 | -H "Authorization: Bearer " \ 80 | -H "X-GitHub-Api-Version: 2022-11-28" \ 81 | https://api.github.com/rate_limit 82 | ``` 83 | 84 | ## Stargazers 85 | 86 | [![Stargazers over time](https://starchart.cc/caarlos0/fork-cleaner.svg)](https://starchart.cc/caarlos0/fork-cleaner) 87 | -------------------------------------------------------------------------------- /internal/ui/app.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | "github.com/charmbracelet/bubbles/list" 8 | "github.com/charmbracelet/bubbles/spinner" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/google/go-github/v50/github" 11 | ) 12 | 13 | // AppModel is the UI when the CLI starts, basically loading the repos. 14 | type AppModel struct { 15 | err error 16 | login string 17 | client *github.Client 18 | skipUpstream bool 19 | list list.Model 20 | } 21 | 22 | // NewAppModel creates a new AppModel with required fields. 23 | func NewAppModel(client *github.Client, login string, skipUpstream bool) AppModel { 24 | list := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) 25 | list.Title = "Fork Cleaner" 26 | list.SetSpinner(spinner.MiniDot) 27 | list.AdditionalShortHelpKeys = func() []key.Binding { 28 | return []key.Binding{ 29 | keySelectToggle, 30 | keyDeletedSelected, 31 | } 32 | } 33 | list.AdditionalFullHelpKeys = func() []key.Binding { 34 | return []key.Binding{ 35 | keySelectAll, 36 | keySelectNone, 37 | } 38 | } 39 | 40 | return AppModel{ 41 | client: client, 42 | login: login, 43 | skipUpstream: skipUpstream, 44 | list: list, 45 | } 46 | } 47 | 48 | func (m AppModel) Init() tea.Cmd { 49 | return tea.Batch(enqueueGetReposCmd, m.list.StartSpinner()) 50 | } 51 | 52 | func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 53 | var cmds []tea.Cmd 54 | var cmd tea.Cmd 55 | 56 | switch msg := msg.(type) { 57 | case tea.WindowSizeMsg: 58 | log.Println("tea.WindowSizeMsg") 59 | top, right, bottom, left := listStyle.GetMargin() 60 | m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) 61 | case errMsg: 62 | log.Println("errMsg") 63 | m.err = msg.error 64 | case getRepoListMsg: 65 | log.Println("getRepoListMsg") 66 | cmds = append(cmds, m.list.StartSpinner(), getReposCmd(m.client, m.login, m.skipUpstream)) 67 | case gotRepoListMsg: 68 | log.Println("gotRepoListMsg") 69 | m.list.StopSpinner() 70 | cmds = append(cmds, m.list.SetItems(reposToItems(msg.repos))) 71 | case reposDeletedMsg: 72 | log.Println("reposDeletedMsg") 73 | cmds = append(cmds, m.list.StartSpinner(), enqueueGetReposCmd) 74 | case requestDeleteSelectedReposMsg: 75 | log.Println("requestDeleteSelectedReposMsg") 76 | selected, unselected := splitBySelection(m.list.Items()) 77 | cmds = append( 78 | cmds, 79 | m.list.SetItems(reposToItems(unselected)), 80 | deleteReposCmd(m.client, selected), 81 | ) 82 | 83 | case tea.KeyMsg: 84 | if m.list.SettingFilter() { 85 | break 86 | } 87 | 88 | if key.Matches(msg, keySelectAll) { 89 | log.Println("tea.KeyMsg -> selectAll") 90 | cmds = append(cmds, m.changeSelect(true)...) 91 | } 92 | 93 | if key.Matches(msg, keySelectNone) { 94 | log.Println("tea.KeyMsg -> selectNone") 95 | cmds = append(cmds, m.changeSelect(false)...) 96 | } 97 | 98 | if key.Matches(msg, keySelectToggle) { 99 | log.Println("tea.KeyMsg -> selectToggle") 100 | cmds = append(cmds, m.toggleSelection()) 101 | } 102 | 103 | if key.Matches(msg, keyDeletedSelected) { 104 | log.Println("tea.KeyMsg -> deleteSelected") 105 | cmds = append(cmds, m.list.StartSpinner(), requestDeleteReposCmd) 106 | } 107 | } 108 | 109 | m.list, cmd = m.list.Update(msg) 110 | cmds = append(cmds, cmd) 111 | return m, tea.Batch(cmds...) 112 | } 113 | 114 | func (m AppModel) View() string { 115 | if m.err != nil { 116 | return errorStyle.Bold(true).Render("Error gathering the repository list") + 117 | "\n" + 118 | errorStyle.Render(m.err.Error()) 119 | } 120 | return m.list.View() 121 | } 122 | 123 | func (m AppModel) toggleSelection() tea.Cmd { 124 | idx := m.list.Index() 125 | item := m.list.SelectedItem().(item) 126 | item.selected = !item.selected 127 | m.list.RemoveItem(idx) 128 | return m.list.InsertItem(idx, item) 129 | } 130 | 131 | func (m AppModel) changeSelect(selected bool) []tea.Cmd { 132 | var cmds []tea.Cmd 133 | for idx, i := range m.list.Items() { 134 | item := i.(item) 135 | item.selected = selected 136 | m.list.RemoveItem(idx) 137 | cmds = append(cmds, m.list.InsertItem(idx, item)) 138 | } 139 | return cmds 140 | } 141 | -------------------------------------------------------------------------------- /fork-cleaner.go: -------------------------------------------------------------------------------- 1 | // Package forkcleaner provides functions to find and remove unused forks. 2 | package forkcleaner 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "github.com/google/go-github/v50/github" 13 | ) 14 | 15 | const pageSize = 100 16 | 17 | type RepositoryWithDetails struct { 18 | Name string 19 | ParentName string 20 | RepoURL string 21 | Private bool 22 | ParentDeleted bool 23 | ParentDMCATakeDown bool 24 | Forks int 25 | Stars int 26 | OpenPRs int 27 | CommitsAhead int 28 | LastUpdate time.Time 29 | } 30 | 31 | // FindAllForks lists all the forks for the current user. 32 | func FindAllForks(ctx context.Context, client *github.Client, login string, skipUpstream bool) ([]*RepositoryWithDetails, error) { 33 | var forks []*RepositoryWithDetails 34 | repos, err := getAllRepos(ctx, client, login) 35 | if err != nil { 36 | return forks, nil 37 | } 38 | for _, r := range repos { 39 | login := r.GetOwner().GetLogin() 40 | name := r.GetName() 41 | 42 | // Get repository as List omits parent information. 43 | repo, resp, err := client.Repositories.Get(ctx, login, name) 44 | switch resp.StatusCode { 45 | case http.StatusForbidden: 46 | // no access, ignore 47 | continue 48 | case http.StatusUnavailableForLegalReasons: 49 | // fork DCMA taken down, so will the parent 50 | forks = append(forks, buildDetails(r, nil, nil, resp.StatusCode)) 51 | continue 52 | } 53 | 54 | if err != nil { 55 | return forks, fmt.Errorf("failed to get repository: %s: %w", repo.GetFullName(), err) 56 | } 57 | 58 | if skipUpstream { 59 | forks = append(forks, buildDetails(repo, nil, nil, resp.StatusCode)) 60 | continue 61 | } 62 | 63 | parent := repo.GetParent() 64 | 65 | // get parent's Issues 66 | issues, err := getIssues(ctx, client, login, parent) 67 | if err != nil { 68 | return forks, fmt.Errorf("failed to get repository's issues: %s: %w", parent.GetFullName(), err) 69 | } 70 | 71 | // compare Commits with parent 72 | commits, resp, err := client.Repositories.CompareCommits( 73 | ctx, 74 | parent.GetOwner().GetLogin(), 75 | parent.GetName(), 76 | parent.GetDefaultBranch(), 77 | fmt.Sprintf("%s:%s", login, repo.GetDefaultBranch()), 78 | &github.ListOptions{}, 79 | ) 80 | if err != nil && resp.StatusCode != 404 { 81 | return forks, fmt.Errorf("failed to compare repository with parent: %s: %w", repo.GetFullName(), err) 82 | } 83 | forks = append(forks, buildDetails(repo, issues, commits, resp.StatusCode)) 84 | 85 | } 86 | 87 | return forks, nil 88 | } 89 | 90 | func buildDetails(repo *github.Repository, issues []*github.Issue, commits *github.CommitsComparison, code int) *RepositoryWithDetails { 91 | var openPrs, aheadBy int 92 | for _, issue := range issues { 93 | if issue.IsPullRequest() { 94 | openPrs++ 95 | } 96 | } 97 | if commits != nil { 98 | aheadBy = commits.GetAheadBy() 99 | } 100 | 101 | return &RepositoryWithDetails{ 102 | Name: repo.GetFullName(), 103 | ParentName: repo.GetParent().GetFullName(), 104 | RepoURL: repo.GetURL(), 105 | Private: repo.GetPrivate(), 106 | ParentDeleted: code == http.StatusNotFound, 107 | ParentDMCATakeDown: code == http.StatusUnavailableForLegalReasons, 108 | Forks: repo.GetForksCount(), 109 | Stars: repo.GetStargazersCount(), 110 | OpenPRs: openPrs, 111 | CommitsAhead: aheadBy, 112 | LastUpdate: repo.GetUpdatedAt().Time, 113 | } 114 | } 115 | 116 | func getAllRepos( 117 | ctx context.Context, 118 | client *github.Client, 119 | login string, 120 | ) ([]*github.Repository, error) { 121 | var allRepos []*github.Repository 122 | 123 | opts := &github.SearchOptions{ 124 | Sort: "created", 125 | Order: "asc", 126 | TextMatch: false, 127 | ListOptions: github.ListOptions{ 128 | PerPage: pageSize, 129 | }, 130 | } 131 | for { 132 | repos, resp, err := client.Search.Repositories(ctx, "owner:"+login+" fork:only", opts) 133 | if err != nil { 134 | return allRepos, err 135 | } 136 | allRepos = append(allRepos, repos.Repositories...) 137 | if resp.NextPage == 0 { 138 | break 139 | } 140 | opts.ListOptions.Page = resp.NextPage 141 | } 142 | 143 | return allRepos, nil 144 | } 145 | 146 | func getIssues( 147 | ctx context.Context, 148 | client *github.Client, 149 | login string, 150 | repo *github.Repository, 151 | ) ([]*github.Issue, error) { 152 | var allIssues []*github.Issue 153 | opts := &github.IssueListByRepoOptions{ 154 | ListOptions: github.ListOptions{ 155 | PerPage: pageSize, 156 | }, 157 | Creator: login, 158 | } 159 | for { 160 | issues, resp, err := client.Issues.ListByRepo( 161 | ctx, 162 | repo.GetOwner().GetLogin(), 163 | repo.GetName(), 164 | opts, 165 | ) 166 | if err != nil { 167 | return allIssues, err 168 | } 169 | allIssues = append(allIssues, issues...) 170 | if resp.NextPage == 0 { 171 | break 172 | } 173 | opts.ListOptions.Page = resp.NextPage 174 | } 175 | return allIssues, nil 176 | } 177 | 178 | // Delete delete the given list of forks. 179 | func Delete( 180 | ctx context.Context, 181 | client *github.Client, 182 | deletions []*RepositoryWithDetails, 183 | ) error { 184 | for _, repo := range deletions { 185 | parts := strings.Split(repo.Name, "/") 186 | log.Println("deleting repository:", repo.Name) 187 | _, err := client.Repositories.Delete(ctx, parts[0], parts[1]) 188 | if err != nil { 189 | return fmt.Errorf("couldn't delete repository: %s: %w", repo.Name, err) 190 | } 191 | } 192 | return nil 193 | } 194 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE= 2 | github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= 3 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 4 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 7 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 8 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 9 | github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 10 | github.com/caarlos0/timea.go v1.2.0 h1:JkjyWSUheN4nGO/OmYVGKbEv4ozHP/zuTZWD5Ih3Gog= 11 | github.com/caarlos0/timea.go v1.2.0/go.mod h1:p4uopjR7K+y0Oxh7j0vLh3vSo58jjzOgXHKcyKwQjuY= 12 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 13 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 14 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 15 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 16 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 17 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 18 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 19 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 20 | github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 21 | github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 22 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 23 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 24 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 25 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 26 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 27 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 28 | github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= 29 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 30 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 31 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 32 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 33 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 34 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 35 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 36 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 37 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 38 | github.com/google/go-github/v50 v50.2.0 h1:j2FyongEHlO9nxXLc+LP3wuBSVU9mVxfpdYUexMpIfk= 39 | github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= 40 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 41 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 42 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 43 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 44 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 45 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 46 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 47 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 48 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 49 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 50 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 51 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 52 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 53 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 54 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 55 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 56 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 57 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 58 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 59 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 60 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 61 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 62 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 63 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 64 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 65 | github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= 66 | github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= 67 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 68 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 69 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 70 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 71 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 72 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 73 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 74 | golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= 75 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 76 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 77 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 78 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 79 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 80 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 81 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 82 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 83 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 84 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 85 | golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 86 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 87 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 88 | golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= 89 | golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 90 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 91 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 92 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 93 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 94 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 95 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 96 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 97 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 100 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 101 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 102 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 103 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 104 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 105 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 106 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 107 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 108 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 109 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 110 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 111 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 112 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 113 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 114 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 115 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 116 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 117 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 118 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 119 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 120 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 121 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 122 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 123 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 124 | --------------------------------------------------------------------------------