├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ └── lint.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── clone-org.go ├── cmd └── clone-org │ └── main.go ├── go.mod ├── go.sum ├── goreleaser.yml └── internal └── ui ├── clone.go ├── common.go ├── help.go ├── initial.go └── repo.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [caarlos0] 2 | -------------------------------------------------------------------------------- /.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 | ignore: 10 | - dependency-name: github.com/charmbracelet/bubbletea 11 | versions: 12 | - 0.13.0 13 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 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 | - run: go mod tidy 22 | - run: go test -v -failfast -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.out ./... -timeout=2m 23 | - uses: codecov/codecov-action@v1 24 | with: 25 | token: ${{ secrets.CODECOV_TOKEN }} 26 | file: ./coverage.txt 27 | - uses: goreleaser/goreleaser-action@v4 28 | if: success() && startsWith(github.ref, 'refs/tags/') 29 | with: 30 | distribution: goreleaser-pro 31 | version: latest 32 | args: release --rm-dist 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 35 | GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 36 | FURY_TOKEN: ${{ secrets.FURY_TOKEN }} 37 | dependabot: 38 | needs: [build] 39 | runs-on: ubuntu-latest 40 | permissions: 41 | pull-requests: write 42 | contents: write 43 | if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'}} 44 | steps: 45 | - id: metadata 46 | uses: dependabot/fetch-metadata@v2 47 | with: 48 | github-token: "${{ secrets.GITHUB_TOKEN }}" 49 | - run: | 50 | gh pr review --approve "$PR_URL" 51 | gh pr merge --squash --auto "$PR_URL" 52 | env: 53 | PR_URL: ${{github.event.pull_request.html_url}} 54 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 55 | -------------------------------------------------------------------------------- /.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 | - uses: golangci/golangci-lint-action@v3 19 | with: 20 | skip-go-installation: true 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | bin 3 | coverage.txt 4 | dist 5 | clone-org.log 6 | coverage.txt 7 | coverage.out 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clone-org 2 | 3 | 4 | [![Release](https://img.shields.io/github/release/caarlos0/clone-org.svg?style=flat-square)](https://github.com/caarlos0/clone-org/releases/latest) 5 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 6 | [![Build status](https://img.shields.io/github/actions/workflow/status/caarlos0/clone-org/build.yml?style=flat-square&branch=main)](https://github.com/caarlos0/clone-org/actions?workflow=build) 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/caarlos0/clone-org?style=flat-square)](https://goreportcard.com/report/github.com/caarlos0/clone-org) 8 | [![Godoc](https://godoc.org/github.com/caarlos0/clone-org?status.svg&style=flat-square)](http://godoc.org/github.com/caarlos0/clone-org) 9 | [![Powered By: GoReleaser](https://img.shields.io/badge/powered%20by-goreleaser-green.svg?style=flat-square)](https://github.com/goreleaser) 10 | 11 | 12 | A simple command line tool to clone all repos of a given organization. 13 | 14 | I needed to do that so I can `grep` all repos for some stuff. GitHub search 15 | wasn't powerful enough to do what I needed, so, here it is. 16 | 17 | ## Usage 18 | 19 | ``` 20 | NAME: 21 | clone-org - Clone all repos of a github organization 22 | 23 | USAGE: 24 | clone-org [global options] command [command options] [arguments...] 25 | 26 | VERSION: 27 | master 28 | 29 | COMMANDS: 30 | help, h Shows a list of commands or help for one command 31 | 32 | GLOBAL OPTIONS: 33 | --org value, -o value 34 | --token value, -t value [$GITHUB_TOKEN] 35 | --destination value, -d value 36 | --help, -h show help 37 | --version, -v print the version 38 | ``` 39 | 40 | ## Notes 41 | 42 | * if no destination is provided, the clone will be made in 43 | `/tmp/organization-name` 44 | * a `git clone --depth 1` will be performed, meaning that only the last commit 45 | of the default branch will be available. On future versions this may be 46 | configurable. 47 | 48 | 49 | ## Install 50 | 51 | **go**: 52 | 53 | ```sh 54 | go install -v github.com/caarlos0/clone-org/cmd/clone-org@latest 55 | ``` 56 | 57 | **homebrew**: 58 | 59 | ```sh 60 | brew install caarlos0/tap/clone-org 61 | ``` 62 | 63 | **macports**: 64 | 65 | ```sh 66 | sudo port install clone-org 67 | ``` 68 | 69 | **snap**: 70 | 71 | ```sh 72 | snap install clone-org 73 | ``` 74 | 75 | **apt**: 76 | 77 | ```sh 78 | echo 'deb [trusted=yes] https://repo.caarlos0.dev/apt/ /' | sudo tee /etc/apt/sources.list.d/caarlos0.list 79 | sudo apt update 80 | sudo apt install clone-org 81 | ``` 82 | 83 | **yum**: 84 | 85 | ```sh 86 | echo '[caarlos0] 87 | name=caarlos0 88 | baseurl=https://repo.caarlos0.dev/yum/ 89 | enabled=1 90 | gpgcheck=0' | sudo tee /etc/yum.repos.d/caarlos0.repo 91 | sudo yum install clone-org 92 | ``` 93 | 94 | **deb/rpm/apk**: 95 | 96 | Download the `.apk`, `.deb` or `.rpm` from the [releases page][releases] and install with the appropriate commands. 97 | 98 | **manually**: 99 | 100 | Download the pre-compiled binaries from the [releases page][releases] or clone the repo build from source. 101 | 102 | [releases]: https://github.com/caarlos0/clone-org/releases 103 | 104 | 105 | ## Stargazers over time 106 | 107 | [![Stargazers over time](https://starchart.cc/caarlos0/clone-org.svg)](https://starchart.cc/caarlos0/clone-org) 108 | -------------------------------------------------------------------------------- /clone-org.go: -------------------------------------------------------------------------------- 1 | // Package cloneorg contains useful functions to find and clone a github 2 | // organization repositories. 3 | package cloneorg 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | 13 | "github.com/google/go-github/v51/github" 14 | "golang.org/x/oauth2" 15 | ) 16 | 17 | // Repo represents the repository data we need. 18 | type Repo struct { 19 | Name string 20 | URL string 21 | } 22 | 23 | // ErrClone happens when a git clone fails. 24 | var ErrClone = errors.New("git clone failed") 25 | 26 | // ErrCreateDir happens when we fail to create the target directory. 27 | var ErrCreateDir = errors.New("failed to create directory") 28 | 29 | var sem = make(chan bool, 20) 30 | 31 | // Clone a given repository into a given destination. 32 | func Clone(repo Repo, destination string) error { 33 | sem <- true 34 | defer func() { 35 | <-sem 36 | }() 37 | 38 | // nolint: gosec 39 | cmd := exec.Command( 40 | "git", "clone", "--depth", "1", repo.URL, 41 | filepath.Join(destination, repo.Name), 42 | ) 43 | if bts, err := cmd.CombinedOutput(); err != nil { 44 | return fmt.Errorf("%w: %v: %v", ErrClone, repo.Name, string(bts)) 45 | } 46 | return nil 47 | } 48 | 49 | // AllOrgRepos finds all repositories of a given organization. 50 | func AllOrgRepos(token, org string) (repos []Repo, err error) { 51 | ctx := context.Background() 52 | client := github.NewClient(oauth2.NewClient( 53 | ctx, 54 | oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}), 55 | )) 56 | result, err := findRepos(ctx, client, org) 57 | if err != nil { 58 | return 59 | } 60 | for _, repo := range result { 61 | repos = append(repos, Repo{ 62 | Name: *repo.Name, 63 | URL: *repo.SSHURL, 64 | }) 65 | } 66 | return 67 | } 68 | 69 | const pageSize = 30 70 | 71 | func findRepos(ctx context.Context, client *github.Client, org string) (result []*github.Repository, err error) { 72 | opt := &github.RepositoryListByOrgOptions{ 73 | ListOptions: github.ListOptions{PerPage: pageSize}, 74 | } 75 | for { 76 | repos, resp, err := client.Repositories.ListByOrg(ctx, org, opt) 77 | if err != nil { 78 | return result, err 79 | } 80 | result = append(result, repos...) 81 | if resp.NextPage == 0 { 82 | break 83 | } 84 | opt.ListOptions.Page = resp.NextPage 85 | } 86 | return result, nil 87 | } 88 | 89 | // CreateDir creates the directory if it does not exists. 90 | func CreateDir(directory string) error { 91 | stat, err := os.Stat(directory) 92 | directoryDoesNotExists := err != nil 93 | 94 | if directoryDoesNotExists { 95 | err := os.MkdirAll(directory, 0o700) 96 | if err != nil { 97 | return fmt.Errorf("%w: %s: %s", ErrCreateDir, directory, err.Error()) 98 | } 99 | 100 | return nil 101 | } 102 | 103 | if stat.IsDir() { 104 | return nil 105 | } 106 | 107 | return fmt.Errorf("%w: %s is a file", ErrCreateDir, directory) 108 | } 109 | -------------------------------------------------------------------------------- /cmd/clone-org/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "runtime/debug" 10 | 11 | "github.com/caarlos0/clone-org/internal/ui" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/mattn/go-isatty" 14 | "github.com/urfave/cli" 15 | ) 16 | 17 | var version = "devel" 18 | 19 | func main() { 20 | if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" { 21 | version = fmt.Sprintf("%s, checksum %s", info.Main.Version, info.Main.Sum) 22 | } 23 | 24 | app := cli.NewApp() 25 | app.Name = "clone-org" 26 | app.Usage = "Clone all repos of a github organization" 27 | app.Version = version 28 | app.Flags = []cli.Flag{ 29 | cli.StringFlag{ 30 | Name: "org, o", 31 | Usage: "organization to clone", 32 | }, 33 | cli.StringFlag{ 34 | Name: "token, t", 35 | EnvVar: "GITHUB_TOKEN", 36 | Usage: "github token to use to authenticate and gather the repository list", 37 | }, 38 | cli.StringFlag{ 39 | Name: "destination, d", 40 | Usage: "path to clone the repositories into", 41 | }, 42 | cli.BoolFlag{ 43 | Name: "no-tui", 44 | Usage: "disable the TUI and use plain text output only", 45 | }, 46 | } 47 | app.Action = func(c *cli.Context) error { 48 | log.SetFlags(0) 49 | 50 | token := c.String("token") 51 | if token == "" { 52 | return cli.NewExitError("missing github token", 1) 53 | } 54 | 55 | org := c.String("org") 56 | if org == "" { 57 | return cli.NewExitError("missing organization name", 1) 58 | } 59 | 60 | destination := c.String("destination") 61 | if destination == "" { 62 | destination = filepath.Join(os.TempDir(), org) 63 | } 64 | 65 | var opts []tea.ProgramOption 66 | isTUI := isatty.IsTerminal(os.Stdout.Fd()) && !c.Bool("no-tui") 67 | if isTUI { 68 | log.SetOutput(io.Discard) 69 | opts = append(opts, tea.WithAltScreen()) 70 | } else { 71 | opts = []tea.ProgramOption{tea.WithoutRenderer()} 72 | } 73 | 74 | p := tea.NewProgram(ui.NewInitialModel(token, org, destination, isTUI), opts...) 75 | if _, err := p.Run(); err != nil { 76 | return cli.NewExitError(err.Error(), 1) 77 | } 78 | return nil 79 | } 80 | if err := app.Run(os.Args); err != nil { 81 | panic(err) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/caarlos0/clone-org 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.21.0 7 | github.com/charmbracelet/bubbletea v1.3.5 8 | github.com/charmbracelet/lipgloss v1.1.0 9 | github.com/google/go-github/v51 v51.0.0 10 | github.com/mattn/go-isatty v0.0.20 11 | github.com/urfave/cli v1.22.16 12 | golang.org/x/oauth2 v0.30.0 13 | ) 14 | 15 | require ( 16 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 18 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 19 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 20 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 21 | github.com/charmbracelet/x/term v0.2.1 // indirect 22 | github.com/cloudflare/circl v1.3.7 // indirect 23 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 25 | github.com/google/go-querystring v1.1.0 // indirect 26 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 27 | github.com/mattn/go-localereader v0.0.1 // indirect 28 | github.com/mattn/go-runewidth v0.0.16 // indirect 29 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 30 | github.com/muesli/cancelreader v0.2.2 // indirect 31 | github.com/muesli/termenv v0.16.0 // indirect 32 | github.com/rivo/uniseg v0.4.7 // indirect 33 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 34 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 35 | golang.org/x/crypto v0.35.0 // indirect 36 | golang.org/x/sync v0.13.0 // indirect 37 | golang.org/x/sys v0.32.0 // indirect 38 | golang.org/x/text v0.22.0 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 2 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= 3 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 6 | github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 7 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 8 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 9 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 10 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 11 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 12 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 13 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 14 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 15 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 16 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 17 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 18 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 19 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 20 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 21 | github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= 22 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 23 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 24 | github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 25 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 26 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 28 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 29 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 30 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 31 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 32 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 33 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 34 | github.com/google/go-github/v51 v51.0.0 h1:KCjsbgPV28VoRftdP+K2mQL16jniUsLAJknsOVKwHyU= 35 | github.com/google/go-github/v51 v51.0.0/go.mod h1:kZj/rn/c1lSUbr/PFWl2hhusPV7a5XNYKcwPrd5L3Us= 36 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 37 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 38 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 39 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 40 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 41 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 42 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 43 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 44 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 45 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 46 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 47 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 48 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 49 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 50 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 51 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 52 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 53 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 54 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 55 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 56 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 57 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 58 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 59 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 60 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 61 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 62 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 63 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 64 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 65 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 66 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 67 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 68 | github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= 69 | github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= 70 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 71 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 72 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 73 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 74 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 75 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 76 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 77 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 78 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 79 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 80 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 81 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 82 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 83 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 | golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 86 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 88 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 89 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 90 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 91 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 92 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 93 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 94 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 95 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 96 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 97 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 98 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 99 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 100 | -------------------------------------------------------------------------------- /goreleaser.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | homepage: https://github.com/caarlos0/clone-org 3 | repository: https://github.com/caarlos0/clone-org 4 | description: Clone all repos of a GitHub user or organization 5 | 6 | includes: 7 | - from_url: 8 | url: https://raw.githubusercontent.com/caarlos0/.goreleaserfiles/main/package.yml 9 | - from_url: 10 | url: https://raw.githubusercontent.com/caarlos0/.goreleaserfiles/main/release.yml 11 | 12 | furies: 13 | - account: caarlos0 14 | 15 | before: 16 | hooks: 17 | - go mod tidy 18 | 19 | gomod: 20 | proxy: true 21 | 22 | builds: 23 | - env: 24 | - CGO_ENABLED=0 25 | main: ./cmd/clone-org 26 | goos: 27 | - linux 28 | - darwin 29 | - windows 30 | goarch: 31 | - amd64 32 | - arm64 33 | mod_timestamp: '{{ .CommitTimestamp }}' 34 | flags: 35 | - -trimpath 36 | ldflags: 37 | - -s -w -X main.version={{ .Version }} -X main.commit={{ .Commit }} -X main.date={{ .CommitDate }} -X main.builtBy=goreleaser 38 | 39 | universal_binaries: 40 | - replace: true 41 | -------------------------------------------------------------------------------- /internal/ui/clone.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | cloneorg "github.com/caarlos0/clone-org" 7 | "github.com/charmbracelet/bubbles/viewport" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | func newCloneModel(repos []cloneorg.Repo, org, destination string, tui bool, w, h int) tea.Model { 13 | var models []repoModel 14 | for _, r := range repos { 15 | models = append(models, newRepoView(r, destination)) 16 | } 17 | 18 | m := cloneModel{ 19 | repos: models, 20 | org: org, 21 | destination: destination, 22 | tui: tui, 23 | } 24 | margin := lipgloss.Height(footer) + lipgloss.Height(header(m)) 25 | vp := viewport.New(w, h-margin) 26 | vp.YPosition = 1 27 | m.viewport = vp 28 | return m 29 | } 30 | 31 | // ListModel is the UI in which the user can select which forks should be 32 | // deleted if any, and see details on each of them. 33 | type cloneModel struct { 34 | repos []repoModel 35 | viewport viewport.Model 36 | org string 37 | destination string 38 | done bool 39 | tui bool 40 | } 41 | 42 | func (m cloneModel) Init() tea.Cmd { 43 | inits := []tea.Cmd{m.viewport.Init()} 44 | for _, r := range m.repos { 45 | inits = append(inits, r.Init()) 46 | } 47 | return tea.Batch(inits...) 48 | } 49 | 50 | func (m cloneModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 51 | var cmd tea.Cmd 52 | var cmds []tea.Cmd 53 | switch msg := msg.(type) { 54 | case tea.WindowSizeMsg: 55 | m.viewport.Height = msg.Height - 2 56 | m.viewport.Width = msg.Width 57 | case tea.KeyMsg: 58 | switch msg.String() { 59 | case "ctrl+c", "q", "esc": 60 | return m, tea.Quit 61 | } 62 | } 63 | if m.done { 64 | if !m.tui { 65 | return m, tea.Quit 66 | } 67 | return m, nil 68 | } 69 | m.done = true 70 | 71 | for i := range m.repos { 72 | m.repos[i], cmd = m.repos[i].Update(msg) 73 | cmds = append(cmds, cmd) 74 | if m.repos[i].cloning { 75 | m.done = false 76 | } 77 | } 78 | 79 | var content string 80 | for _, r := range m.repos { 81 | if !r.cloning { 82 | continue 83 | } 84 | content += "\n" + r.View() 85 | } 86 | for _, r := range m.repos { 87 | if r.cloning { 88 | continue 89 | } 90 | content += "\n" + r.View() 91 | } 92 | m.viewport.SetContent(content) 93 | 94 | m.viewport, cmd = m.viewport.Update(msg) 95 | cmds = append(cmds, cmd) 96 | 97 | return m, tea.Batch(cmds...) 98 | } 99 | 100 | func (m cloneModel) View() string { 101 | return header(m) + "\n" + m.viewport.View() + "\n" + footer 102 | } 103 | 104 | var footer = singleOptionHelp("q/esc", "quit") 105 | 106 | func header(m cloneModel) string { 107 | verb := "Cloning" 108 | if m.done { 109 | verb = "Cloned" 110 | } 111 | 112 | return secondaryForeground.Render(fmt.Sprintf( 113 | "%s %d repositories from %s to %s ...", 114 | verb, 115 | len(m.repos), 116 | m.org, 117 | m.destination, 118 | )) 119 | } 120 | -------------------------------------------------------------------------------- /internal/ui/common.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | // light palette: https://colorhunt.co/palette/201882 10 | // dark palette: https://colorhunt.co/palette/273948 11 | var ( 12 | primaryColor = lipgloss.AdaptiveColor{ 13 | Light: "#1a1a2e", 14 | Dark: "#f7f3e9", 15 | } 16 | secondaryColor = lipgloss.AdaptiveColor{ 17 | Light: "#16213e", 18 | Dark: "#a3d2ca", 19 | } 20 | errorColor = lipgloss.AdaptiveColor{ 21 | Light: "#e94560", 22 | Dark: "#f05945", 23 | } 24 | grayColor = lipgloss.Color("#626262") 25 | midGrayColor = lipgloss.Color("#4a4a4a") 26 | 27 | secondaryForeground = lipgloss.NewStyle().Foreground(secondaryColor) 28 | primaryForegroundBold = lipgloss.NewStyle().Bold(true).Foreground(primaryColor) 29 | errorFaintForeground = lipgloss.NewStyle().Foreground(errorColor).Faint(true) 30 | errorForegroundPadded = lipgloss.NewStyle().Padding(4).Foreground(errorColor) 31 | grayForeground = lipgloss.NewStyle().Foreground(grayColor) 32 | midGrayForeground = lipgloss.NewStyle().Foreground(midGrayColor) 33 | ) 34 | 35 | type errMsg struct{ error } 36 | 37 | func (e errMsg) Error() string { return e.error.Error() } 38 | 39 | func errorView(action string, err error) string { 40 | return errorForegroundPadded.Render(fmt.Sprintf(action+": %s.\nCheck the log file for more details.", err.Error())) + 41 | singleOptionHelp("q", "quit") 42 | } 43 | -------------------------------------------------------------------------------- /internal/ui/help.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func singleOptionHelp(k, v string) string { 8 | return helpView([]helpOption{ 9 | {k, v, true}, 10 | }) 11 | } 12 | 13 | var separator = midGrayForeground.Render(" • ") 14 | 15 | func helpView(options []helpOption) string { 16 | var lines []string 17 | 18 | var line []string 19 | for i, help := range options { 20 | if help.primary { 21 | s := grayForeground.Render(help.key) + 22 | " " + 23 | secondaryForeground.Faint(true).Render((help.help)) 24 | line = append(line, s) 25 | } else { 26 | s := grayForeground.Render(help.key) + 27 | " " + 28 | midGrayForeground.Render(help.help) 29 | line = append(line, s) 30 | } 31 | // splits in rows of 3 options max 32 | if (i+1)%3 == 0 { 33 | lines = append(lines, strings.Join(line, separator)) 34 | line = []string{} 35 | } 36 | } 37 | 38 | // append remainder 39 | lines = append(lines, strings.Join(line, separator)) 40 | 41 | return "\n\n" + strings.Join(lines, "\n") 42 | } 43 | 44 | type helpOption struct { 45 | key, help string 46 | primary bool 47 | } 48 | -------------------------------------------------------------------------------- /internal/ui/initial.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "log" 5 | 6 | cloneorg "github.com/caarlos0/clone-org" 7 | "github.com/charmbracelet/bubbles/spinner" 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | // NewInitialModel creates a new InitialModel with required fields. 12 | func NewInitialModel(token, org, destination string, tui bool) tea.Model { 13 | s := spinner.New() 14 | s.Spinner = spinner.Jump 15 | 16 | return initialModel{ 17 | token: token, 18 | org: org, 19 | destination: destination, 20 | spinner: s, 21 | loading: true, 22 | tui: tui, 23 | } 24 | } 25 | 26 | // InitialModel is the UI when the CLI starts, basically loading the repos. 27 | type initialModel struct { 28 | err error 29 | spinner spinner.Model 30 | token string 31 | org string 32 | destination string 33 | loading bool 34 | tui bool 35 | width, height int 36 | } 37 | 38 | func (m initialModel) Init() tea.Cmd { 39 | return tea.Batch(getReposCmd(m.token, m.org, m.destination), m.spinner.Tick) 40 | } 41 | 42 | func (m initialModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 43 | switch msg := msg.(type) { 44 | case tea.WindowSizeMsg: 45 | m.height = msg.Height 46 | m.width = msg.Width 47 | return m, nil 48 | case errMsg: 49 | m.loading = false 50 | m.err = msg.error 51 | return m, nil 52 | case gotRepoListMsg: 53 | list := newCloneModel(msg.repos, m.org, m.destination, m.tui, m.width, m.height) 54 | return list, list.Init() 55 | case tea.KeyMsg: 56 | switch msg.String() { 57 | case "ctrl+c", "q", "esc": 58 | return m, tea.Quit 59 | } 60 | default: 61 | var cmd tea.Cmd 62 | m.spinner, cmd = m.spinner.Update(msg) 63 | return m, cmd 64 | } 65 | return m, nil 66 | } 67 | 68 | func (m initialModel) View() string { 69 | if m.loading { 70 | return primaryForegroundBold.Render(m.spinner.View()) + 71 | " Gathering repositories..." + 72 | singleOptionHelp("q", "quit") 73 | } 74 | if m.err != nil { 75 | return errorView("Error gathering the repositories: ", m.err) 76 | } 77 | return "" 78 | } 79 | 80 | // msgs and cmds 81 | 82 | type gotRepoListMsg struct { 83 | repos []cloneorg.Repo 84 | } 85 | 86 | func getReposCmd(token, org, destination string) tea.Cmd { 87 | return func() tea.Msg { 88 | log.Println("gathering repositories...") 89 | repos, err := cloneorg.AllOrgRepos(token, org) 90 | if err != nil { 91 | return errMsg{err} 92 | } 93 | if err := cloneorg.CreateDir(destination); err != nil { 94 | return errMsg{err} 95 | } 96 | return gotRepoListMsg{repos} 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/ui/repo.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | cloneorg "github.com/caarlos0/clone-org" 8 | "github.com/charmbracelet/bubbles/spinner" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | func newRepoView(repo cloneorg.Repo, destination string) repoModel { 13 | s := spinner.New() 14 | s.Spinner = spinner.Points 15 | return repoModel{ 16 | repo: repo, 17 | destination: destination, 18 | spinner: s, 19 | cloning: true, 20 | } 21 | } 22 | 23 | type repoModel struct { 24 | repo cloneorg.Repo 25 | destination string 26 | spinner spinner.Model 27 | cloning bool 28 | err error 29 | } 30 | 31 | func (m repoModel) Init() tea.Cmd { 32 | return tea.Batch(cloneRepoCmd(m.repo, m.destination), m.spinner.Tick) 33 | } 34 | 35 | func (m repoModel) Update(msg tea.Msg) (repoModel, tea.Cmd) { 36 | switch msg := msg.(type) { 37 | case repoCloneErrMsg: 38 | if msg.name == m.repo.Name { 39 | m.cloning = false 40 | m.err = msg.error 41 | log.Println("failed to clone", m.repo.Name, m.err) 42 | } 43 | case repoClonedMsg: 44 | if msg.name == m.repo.Name { 45 | m.cloning = false 46 | log.Println("cloned", m.repo.Name) 47 | } 48 | case tea.KeyMsg: 49 | switch msg.String() { 50 | case "ctrl+c", "q", "esc": 51 | return m, tea.Quit 52 | } 53 | } 54 | 55 | var cmd tea.Cmd 56 | m.spinner, cmd = m.spinner.Update(msg) 57 | return m, cmd 58 | } 59 | 60 | func (m repoModel) View() string { 61 | if m.err != nil { 62 | return secondaryForeground.Render("[failed] ") + 63 | m.repo.Name + 64 | " " + 65 | errorFaintForeground.Render(strings.TrimSpace(m.err.Error())) 66 | } 67 | if m.cloning { 68 | return secondaryForeground.Render("[cloning] ") + 69 | m.repo.Name + 70 | " " + 71 | primaryForegroundBold.Render(m.spinner.View()) 72 | } 73 | return secondaryForeground.Render("[cloned] ") + 74 | m.repo.Name 75 | } 76 | 77 | // msgs and cmds 78 | 79 | type repoClonedMsg struct { 80 | name string 81 | } 82 | 83 | type repoCloneErrMsg struct { 84 | error 85 | name string 86 | } 87 | 88 | func cloneRepoCmd(repo cloneorg.Repo, destination string) tea.Cmd { 89 | return func() tea.Msg { 90 | if err := cloneorg.Clone(repo, destination); err != nil { 91 | return repoCloneErrMsg{err, repo.Name} 92 | } 93 | return repoClonedMsg{repo.Name} 94 | } 95 | } 96 | --------------------------------------------------------------------------------