├── .github └── workflows │ ├── general.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── assets └── cat_sorter.jpeg ├── cmd ├── export.go ├── import.go ├── log.go ├── pull.go ├── root.go ├── status.go ├── switch.go ├── sync.go ├── validate.go └── version.go ├── go.mod ├── go.sum ├── main.go ├── test ├── git_helpers_test.go ├── invalid_example.repos ├── nested_example.repos ├── repos_helpers_test.go ├── valid_example.repos └── valid_example.rosinstall └── utils ├── git_helpers.go ├── print_helpers.go └── repos_helpers.go /.github/workflows/general.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: 3 | push: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | gofmt-check: 10 | name: Check Go Formatting 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Setup Go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: "1.24.x" 19 | 20 | - name: Run gofmt 21 | run: | 22 | if [ -n "$(gofmt -l .)" ]; then 23 | echo "The following files are not formatted properly:" 24 | gofmt -l -d . 25 | exit 1 26 | fi 27 | build: 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Setup Go 33 | uses: actions/setup-go@v5 34 | with: 35 | go-version: "1.24.x" 36 | - name: Install dependencies 37 | run: go get . 38 | - name: Build 39 | run: go build -v ./... 40 | 41 | test: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | - name: Setup Go 46 | uses: actions/setup-go@v5 47 | with: 48 | go-version: "1.24.x" 49 | - name: Install dependencies 50 | run: go get . 51 | - name: Test with the Go CLI 52 | run: go test -v ./test 53 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version_bump: 7 | description: 'Version bump type' 8 | type: choice 9 | required: true 10 | default: 'patch' 11 | options: 12 | - major 13 | - minor 14 | - patch 15 | 16 | jobs: 17 | bump-and-release: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout the project 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - name: Setup Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: '1.24.x' 28 | 29 | - name: Get Latest Tag 30 | run: | 31 | latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1) || echo "v0.0.0") 32 | 33 | if ! [[ $latest_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 34 | echo "Error: Tag format is invalid. Expected format: vX.X.X" 35 | exit 1 36 | fi 37 | 38 | echo "Latest tag: $latest_tag" 39 | echo "latest_tag=$latest_tag" >> $GITHUB_ENV 40 | 41 | - name: Check for changes since last release 42 | run: | 43 | if [ -z "$(git diff --name-only ${{ env.latest_tag }})" ]; then 44 | echo "No changes detected since last release" 45 | exit 1 46 | fi 47 | 48 | - name: Calculate next version 49 | run: | 50 | echo "Latest tag: ${{ env.latest_tag }}" 51 | 52 | # Remove any existing 'v' prefix 53 | clean_tag="${{ env.latest_tag }}" 54 | clean_tag="${clean_tag#v}" 55 | 56 | IFS='.' read -r major minor patch <<< "$clean_tag" 57 | 58 | if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then 59 | if [[ "${{ inputs.version_bump }}" == "major" ]]; then 60 | major=$((major + 1)) 61 | minor=0 62 | patch=0 63 | elif [[ "${{ inputs.version_bump }}" == "minor" ]]; then 64 | minor=$((minor + 1)) 65 | patch=0 66 | else 67 | patch=$((patch + 1)) 68 | fi 69 | fi 70 | 71 | new_tag="v$major.$minor.$patch" 72 | 73 | if ! [[ $new_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 74 | echo "Error: New tag's format is invalid ${new_tag}. Expected format: vX.X.X" 75 | exit 1 76 | fi 77 | echo "New tag: $new_tag" 78 | echo "new_tag=$new_tag" >> $GITHUB_ENV 79 | 80 | - name: Update version.go 81 | run: | 82 | VERSION="${{ env.new_tag }}" 83 | COMMIT=$(git rev-parse --short HEAD) 84 | DATE=$(date +'%d.%m.%Y') 85 | BRANCH=$(git rev-parse --abbrev-ref HEAD) 86 | sed -i "s/Version = \".*\"/Version = \"$VERSION\"/" ./cmd/version.go 87 | sed -i "s/Commit = \".*\"/Commit = \"$COMMIT\"/" ./cmd/version.go 88 | sed -i "s/BuildDate = \".*\"/BuildDate = \"$DATE\"/" ./cmd/version.go 89 | 90 | git config --global user.name 'github-actions[bot]' 91 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 92 | git add . 93 | git commit -m "Update version.go for release $VERSION" 94 | git push origin $BRANCH 95 | env: 96 | GITHUB_TOKEN: ${{ secrets.GH_API_TOKEN }} 97 | 98 | - name: Create and Push Tag 99 | run: | 100 | git config user.name "github-actions[bot]" 101 | git config user.email "github-actions[bot]@users.noreply.github.com" 102 | git tag ${{ env.new_tag }} 103 | git push origin ${{ env.new_tag }} 104 | env: 105 | GITHUB_TOKEN: ${{ secrets.GH_API_TOKEN }} 106 | 107 | - name: Run GoReleaser 108 | uses: goreleaser/goreleaser-action@v6 109 | with: 110 | distribution: goreleaser 111 | version: latest 112 | args: release --clean 113 | env: 114 | GITHUB_TOKEN: ${{ secrets.GH_API_TOKEN }} 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | rv 2 | 3 | dist/ 4 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | archives: 4 | - format: binary 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ErickKramer 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 | # 🎒 ripvcs 2 | 3 |

4 | 5 |

6 | 7 | 8 | _ripvcs (rv)_ is a command-line tool written in Go, providing an efficient alternative to [vcstool](https://github.com/dirk-thomas/vcstool) for managing multiple repository workspaces. 9 | 10 | Whether you are managing a few repositories or a complex workspace with numerous nested repositories, ripvcs (rv) offers the performance and efficiency you need to keep your workflow smooth and responsive. 11 | 12 | ## 🪄 Features 13 | 14 | - **Enhanced Concurrency:** Utilizes Go routines to manage multiple tasks simultaneously, 15 | ensuring optimal performance and reducing wait times. 16 | - **Recursive Import:** Supports recursive import functionality to automatically search for `.repos` 17 | files within directories to streamline the process of managed nested 18 | repositories dependencies. 19 | 20 | ## 🧰 Installation 21 | 22 | ### Using pre-built binaries 23 | 24 | - Latest release 25 | 26 | ```console 27 | RIPVCS_VERSION=$(curl -s "https://api.github.com/repos/ErickKramer/ripvcs/releases/latest" | \grep -Po '"tag_name": *"v\K[^"]*') 28 | ARCHITECTURE="linux_amd64" 29 | curl -Lo ~/.local/bin/rv "https://github.com/ErickKramer/ripvcs/releases/download/v${RIPVCS_VERSION}/ripvcs_${RIPVCS_VERSION}_${ARCHITECTURE}" 30 | chmod +x ~/.local/bin/rv 31 | ``` 32 | 33 | ## Build from source 34 | 35 | 1. Clone repository 36 | ```console 37 | git clone https://github.com/ErickKramer/ripvcs 38 | cd ripvcs 39 | ``` 40 | 2. Build binary 41 | ```console 42 | go build -o rv main.go 43 | ``` 44 | 3. Move rv to path 45 | ```console 46 | mv rv ~/.local/bin/rv 47 | chmod +x ~/.local/bin/rv 48 | ``` 49 | ## Usage 50 | 51 | To check the available commands in _rv_ simply run `rv help`: 52 | 53 | ```console 54 | rv help 55 | 56 | Fast CLI tool for managing multiple Git repositories. 57 | 58 | Usage: 59 | rv [command] 60 | 61 | Available Commands: 62 | completion Generate the autocompletion script for the specified shell 63 | export Export list of available repositories 64 | help Help about any command 65 | import Import repositories listed in the given .repos file 66 | log Get logs of all repositories. 67 | pull Pull latest version from remote. 68 | status Check status of all repositories 69 | switch Switch repository version 70 | sync Synchronize all found repositories. 71 | validate Validate a .repos file 72 | version Print the version number 73 | ``` 74 | 75 | Each of the available commands have their own help with information about their usage and available flags (e.g. `rv help import`). 76 | 77 | ```console 78 | Import repositories listed in the given .repos file 79 | 80 | The repositories are cloned in the given path or in the current path. 81 | 82 | It supports recursively searching for any other .repos file found at each 83 | import cycle. 84 | 85 | Usage: 86 | rv import [flags] 87 | 88 | Flags: 89 | -d, --depth-recursive int Regulates how many levels the recursive dependencies would be cloned. (default -1) 90 | -x, --exclude strings List of files and/or directories to exclude when performing a recursive import 91 | -f, --force Force overwriting existing repositories 92 | -h, --help help for import 93 | -i, --input .repos Path to input .repos file 94 | -s, --recurse-submodules Recursively clone submodules 95 | -r, --recursive .repos Recursively search of other .repos file in the cloned repositories 96 | -n, --retry int Number of attempts to import repositories (default 2) 97 | -l, --shallow Clone repositories with a depth of 1 98 | -w, --workers int Number of concurrent workers to use (default 8) 99 | ``` 100 | 101 | ### Repositories file 102 | 103 | ```yaml 104 | repositories: 105 | demos: 106 | type: git 107 | url: https://github.com/ros2/demos 108 | version: jazzy 109 | stable_demos: 110 | type: git 111 | url: https://github.com/ros2/demos 112 | version: 0.20.4 113 | default_demos: 114 | type: git 115 | url: https://github.com/ros2/demos 116 | exclude: [] 117 | ``` 118 | 119 | ### Import exclusion 120 | 121 | It is possible to exclude files or directories when doing recursive import. This can be done either 122 | through the use of the `--exclude / -x` flag accompanied by the name of the `.repos` file or a 123 | directory within the path. 124 | 125 | Additionally, it is possible to add a `exclude` attribute to the `.repos` file to hard-code what 126 | files to exclude during import. An example of this can be seen in [nested_example.repos](./test/nested_example.repos) 127 | 128 | ## Related Project 129 | 130 | - [vcstool](https://github.com/dirk-thomas/vcstool) 131 | 132 | ## Shell completions 133 | 134 | `rv` supports generating an autocompletion script for bash, fish, powershell, and zsh 135 | 136 | For example, to configure to generate the completion for zsh do the following 137 | 138 | ```console 139 | rv completion zsh _ripvcs 140 | ``` 141 | 142 | Then, you need to place the completion file in the proper location to be loaded by your zsh configuration. 143 | 144 | Afterwards, you should be able to do `rv ` to get autocompletion for the available commands and `rv import -` to get autocompletion for the available flags. 145 | 146 | ### Runtime comparison 147 | 148 | I ran a quick comparison between `rv` and `vcs` using the [valid_example.repos](./test/valid_example.repos) file for different commands using `8` workers. 149 | 150 | | Command | vcs | rv | 151 | | :----------------: | :-----: | :-----: | 152 | | import (overwrite) | 2.363 s | 1.753 s | 153 | | import + skip | 0.691 s | 0.004 s | 154 | | log | 0.248 s | 0.021 s | 155 | | pull | 0.635 s | 0.417 s | 156 | | status | 0.238 s | 0.035 s | 157 | | validate | 0.869 s | 0.414 s | 158 | 159 | ## Future enhancements 160 | 161 | - [ ] Support tar artifacts 162 | - [ ] Support export command 163 | - [ ] Support custom git commands 164 | -------------------------------------------------------------------------------- /assets/cat_sorter.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErickKramer/ripvcs/fd4531a01baee3fbbf2864211c6f0c7d3bfd5a03/assets/cat_sorter.jpeg -------------------------------------------------------------------------------- /cmd/export.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Erick Kramer 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "ripvcs/utils" 11 | 12 | "github.com/spf13/cobra" 13 | "gopkg.in/yaml.v2" 14 | ) 15 | 16 | // statusCmd represents the status command 17 | var exportCmd = &cobra.Command{ 18 | Use: "export ", 19 | Short: "Export list of available repositories", 20 | Long: `Export list of available repositories.. 21 | 22 | If no path is given, it checks the finds any Git repository relative to the current path.`, 23 | Run: func(cmd *cobra.Command, args []string) { 24 | var root string 25 | if len(args) == 0 { 26 | root = "." 27 | } else { 28 | root = utils.GetRepoPath(args[0]) 29 | } 30 | gitRepos := utils.FindGitRepositories(root) 31 | 32 | filePath, _ := cmd.Flags().GetString("output") 33 | visualizeOutput, _ := cmd.Flags().GetBool("visualize") 34 | 35 | skipOutputFile := false 36 | 37 | if len(filePath) == 0 { 38 | if visualizeOutput { 39 | skipOutputFile = true 40 | } else { 41 | utils.PrintErrorMsg("Missing output file.") 42 | os.Exit(1) 43 | } 44 | } 45 | 46 | numWorkers, _ := cmd.Flags().GetInt("workers") 47 | getCommitsFlag, _ := cmd.Flags().GetBool("commits") 48 | 49 | // Create a channel to send work to the workers with a buffer size of length gitRepos 50 | jobs := make(chan string, len(gitRepos)) 51 | repositories := make(chan utils.RepositoryJob, len(gitRepos)) 52 | 53 | // Create a channel to indicate when the go routines have finished 54 | done := make(chan bool) 55 | 56 | var config utils.Config 57 | // Initialize the repositories map 58 | config.Repositories = make(map[string]utils.Repository) 59 | // Iterate over the numWorkers 60 | for range numWorkers { 61 | go func() { 62 | for repoPath := range jobs { 63 | var repoPathName string 64 | if repoPath == "." { 65 | absPath, _ := filepath.Abs(repoPath) 66 | repoPathName = filepath.Base(absPath) 67 | } else { 68 | repoPathName = filepath.Base(repoPath) 69 | } 70 | repo := utils.ParseRepositoryInfo(repoPath, getCommitsFlag) 71 | repositories <- utils.RepositoryJob{RepoPath: repoPathName, Repo: repo} 72 | } 73 | done <- true 74 | }() 75 | } 76 | // Send each git repository path to the jobs channel 77 | for _, repoPath := range gitRepos { 78 | jobs <- repoPath 79 | } 80 | close(jobs) // Close channel to signal no more work will be sent 81 | 82 | // wait for all goroutines to finish 83 | for range numWorkers { 84 | <-done 85 | } 86 | close(repositories) 87 | 88 | for repoResult := range repositories { 89 | config.Repositories[repoResult.RepoPath] = repoResult.Repo 90 | } 91 | yamlData, _ := yaml.Marshal(&config) 92 | if visualizeOutput { 93 | fmt.Println(string(yamlData)) 94 | } 95 | if !skipOutputFile { 96 | err := os.WriteFile(filePath, yamlData, 0644) 97 | if err != nil { 98 | utils.PrintErrorMsg("Failed to export repositories to yaml file.") 99 | os.Exit(1) 100 | } 101 | } 102 | }, 103 | } 104 | 105 | func init() { 106 | rootCmd.AddCommand(exportCmd) 107 | exportCmd.Flags().IntP("workers", "w", 8, "Number of concurrent workers to use") 108 | exportCmd.Flags().StringP("output", "o", "", "Path to output `.repos` file") 109 | exportCmd.Flags().BoolP("commits", "c", false, "Export repositories hashes instead of branches") 110 | exportCmd.Flags().BoolP("visualize", "v", false, "Show the information to be stored in the output file") 111 | } 112 | -------------------------------------------------------------------------------- /cmd/import.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Erick Kramer 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "ripvcs/utils" 11 | "strings" 12 | "sync" 13 | 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | var importCmd = &cobra.Command{ 18 | Use: "import ", 19 | Short: "Import repositories listed in the given .repos file", 20 | Long: `Import repositories listed in the given .repos file 21 | 22 | The repositories are cloned in the given path or in the current path. 23 | 24 | It supports recursively searching for any other .repos file found at each 25 | import cycle.`, 26 | Run: func(cmd *cobra.Command, args []string) { 27 | var cloningPath string 28 | if len(args) == 0 { 29 | cloningPath = "." 30 | } else { 31 | cloningPath = args[0] 32 | } 33 | 34 | // Get arguments 35 | filePath, _ := cmd.Flags().GetString("input") 36 | recursiveFlag, _ := cmd.Flags().GetBool("recursive") 37 | numRetries, _ := cmd.Flags().GetInt("retry") 38 | overwriteExisting, _ := cmd.Flags().GetBool("force") 39 | shallowClone, _ := cmd.Flags().GetBool("shallowClone") 40 | depthRecursive, _ := cmd.Flags().GetInt("depth-recursive") 41 | numWorkers, _ := cmd.Flags().GetInt("workers") 42 | excludeList, _ := cmd.Flags().GetStringSlice("exclude") 43 | recurseSubmodules, _ := cmd.Flags().GetBool("recurse-submodules") 44 | 45 | var hardCodedExcludeList = []string{} 46 | var clonedPaths []string 47 | 48 | // Import repository files in the given file 49 | validFile, hardCodedExcludeList, clonedPaths := singleCloneSweep(cloningPath, filePath, numWorkers, overwriteExisting, shallowClone, numRetries, recurseSubmodules) 50 | if !validFile { 51 | os.Exit(1) 52 | } 53 | if !recursiveFlag { 54 | os.Exit(0) 55 | } 56 | excludeList = append(excludeList, hardCodedExcludeList...) 57 | nestedImportClones(cloningPath, filePath, depthRecursive, numWorkers, overwriteExisting, shallowClone, numRetries, excludeList, recurseSubmodules, clonedPaths) 58 | 59 | }, 60 | } 61 | 62 | func init() { 63 | rootCmd.AddCommand(importCmd) 64 | 65 | importCmd.Flags().IntP("depth-recursive", "d", -1, "Regulates how many levels the recursive dependencies would be cloned.") 66 | importCmd.Flags().StringP("input", "i", "", "Path to input `.repos` file") 67 | importCmd.Flags().BoolP("recursive", "r", false, "Recursively search of other `.repos` file in the cloned repositories") 68 | importCmd.Flags().IntP("retry", "n", 2, "Number of attempts to import repositories") 69 | importCmd.Flags().BoolP("force", "f", false, "Force overwriting existing repositories") 70 | importCmd.Flags().BoolP("shallow", "l", false, "Clone repositories with a depth of 1") 71 | importCmd.Flags().IntP("workers", "w", 8, "Number of concurrent workers to use") 72 | importCmd.Flags().StringSliceP("exclude", "x", []string{}, "List of files and/or directories to exclude when performing a recursive import") 73 | importCmd.Flags().BoolP("recurse-submodules", "s", false, "Recursively clone submodules") 74 | } 75 | 76 | func singleCloneSweep(root string, filePath string, numWorkers int, overwriteExisting bool, shallowClone bool, numRetries int, recurseSubmodules bool) (bool, []string, []string) { 77 | utils.PrintSeparator() 78 | utils.PrintSection(fmt.Sprintf("Importing from %s", filePath)) 79 | utils.PrintSeparator() 80 | config, err := utils.ParseReposFile(filePath) 81 | 82 | var allExcludes []string 83 | var clonedPaths []string 84 | 85 | if err != nil { 86 | utils.PrintErrorMsg(fmt.Sprintf("Invalid file given {%s}. %s\n", filePath, err)) 87 | return false, allExcludes, clonedPaths 88 | } 89 | // Create a channel to send work to the workers with a buffer size of length gitRepos 90 | jobs := make(chan utils.RepositoryJob, len(config.Repositories)) 91 | // Create channel to collect results 92 | results := make(chan bool, len(config.Repositories)) 93 | // Create a channel to indicate when the go routines have finished 94 | done := make(chan bool) 95 | 96 | // Create mutex to handle excludeFilesChannel 97 | var excludeFilesMutex sync.Mutex 98 | 99 | for range numWorkers { 100 | go func() { 101 | for job := range jobs { 102 | if job.Repo.Type != "git" { 103 | utils.PrintRepoEntry(job.RepoPath, "") 104 | utils.PrintErrorMsg(fmt.Sprintf("Unsupported repository type %s.\n", job.Repo.Type)) 105 | results <- false 106 | } else { 107 | success := false 108 | for range numRetries { 109 | success = utils.PrintGitClone(job.Repo.URL, job.Repo.Version, job.RepoPath, overwriteExisting, shallowClone, false, recurseSubmodules) 110 | if success { 111 | clonedPaths = append(clonedPaths, job.RepoPath) 112 | break 113 | } 114 | } 115 | results <- success 116 | // Expand excludeFilesChannel 117 | if len(job.Repo.Exclude) > 0 { 118 | excludeFilesMutex.Lock() 119 | allExcludes = append(allExcludes, job.Repo.Exclude...) 120 | excludeFilesMutex.Unlock() 121 | } 122 | } 123 | } 124 | done <- true 125 | }() 126 | } 127 | 128 | for dirName, repo := range config.Repositories { 129 | jobs <- utils.RepositoryJob{RepoPath: filepath.Join(root, dirName), Repo: repo} 130 | } 131 | close(jobs) 132 | // wait for all goroutines to finish 133 | for range numWorkers { 134 | <-done 135 | } 136 | close(results) 137 | 138 | validFile := true 139 | for result := range results { 140 | if !result { 141 | validFile = false 142 | utils.PrintErrorMsg(fmt.Sprintf("Failed while cloning %s\n", filePath)) 143 | break 144 | } 145 | } 146 | 147 | return validFile, allExcludes, clonedPaths 148 | } 149 | 150 | func nestedImportClones(cloningPath string, initialFilePath string, depthRecursive int, numWorkers int, overwriteExisting bool, shallowClone bool, numRetries int, excludeList []string, recurseSubmodules bool, clonedPaths []string) { 151 | // Recursively import .repos files found 152 | clonedReposFiles := map[string]bool{initialFilePath: true} 153 | validFiles := true 154 | cloneSweepCounter := 0 155 | 156 | numPreviousFoundReposFiles := 0 157 | 158 | for { 159 | // Check if recursion level has been reached 160 | if depthRecursive != -1 && cloneSweepCounter >= depthRecursive { 161 | break 162 | } 163 | 164 | // Find .repos file to clone 165 | foundReposFiles, err := utils.FindReposFiles(cloningPath, clonedPaths) 166 | if err != nil || len(foundReposFiles) == 0 { 167 | break 168 | } 169 | 170 | if len(foundReposFiles) == numPreviousFoundReposFiles { 171 | break 172 | } 173 | numPreviousFoundReposFiles = len(foundReposFiles) 174 | 175 | // Get dependencies to clone 176 | newReposFileFound := false 177 | var hardCodedExcludeList = []string{} 178 | 179 | // FIXME: Find a simpler logic for this 180 | for _, filePathToClone := range foundReposFiles { 181 | // Check if the file is in the exclude list 182 | exclude := false 183 | 184 | // Initialize filePathToClone options 185 | filePathBase := filepath.Base(filePathToClone) 186 | filePathDir := filepath.Dir(filePathToClone) 187 | filePathParentDir := filepath.Base(filePathDir) 188 | 189 | for _, excludePath := range excludeList { 190 | excludeBase := filepath.Base(excludePath) 191 | 192 | // Check if exclude matches either: 193 | // 1. The full relative path 194 | // 2. The filename 195 | // 3. The parent directory 196 | if filePathBase == excludeBase || filePathParentDir == excludeBase || strings.HasPrefix(filePathToClone, excludePath) { 197 | exclude = true 198 | break 199 | } 200 | } 201 | 202 | if _, ok := clonedReposFiles[filePathToClone]; !ok { 203 | if exclude { 204 | utils.PrintSeparator() 205 | utils.PrintWarnMsg(fmt.Sprintf("Excluded cloning from '%s'\n", filePathToClone)) 206 | clonedReposFiles[filePathToClone] = false 207 | continue 208 | } 209 | var newClonedPaths []string 210 | validFiles, hardCodedExcludeList, newClonedPaths = singleCloneSweep(cloningPath, filePathToClone, numWorkers, overwriteExisting, shallowClone, numRetries, recurseSubmodules) 211 | clonedReposFiles[filePathToClone] = true 212 | newReposFileFound = true 213 | clonedPaths = append(clonedPaths, newClonedPaths...) 214 | if !validFiles { 215 | utils.PrintErrorMsg("Encountered errors while importing file") 216 | os.Exit(1) 217 | } 218 | excludeList = append(excludeList, hardCodedExcludeList...) 219 | } 220 | } 221 | if !newReposFileFound { 222 | break 223 | } 224 | cloneSweepCounter++ 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /cmd/log.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Erick Kramer 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "ripvcs/utils" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // logCmd represents the log command 13 | var logCmd = &cobra.Command{ 14 | Use: "log ", 15 | Short: "Get logs of all repositories.", 16 | Long: `Get logs of all repositories. 17 | 18 | If no path is given, it gets the logs of any Git repository relative to the current path.`, 19 | 20 | Run: func(cmd *cobra.Command, args []string) { 21 | var root string 22 | if len(args) == 0 { 23 | root = "." 24 | } else { 25 | root = utils.GetRepoPath(args[0]) 26 | } 27 | gitRepos := utils.FindGitRepositories(root) 28 | 29 | onelineFlag, _ := cmd.Flags().GetBool("oneline") 30 | numWorkers, _ := cmd.Flags().GetInt("workers") 31 | numCommits, _ := cmd.Flags().GetInt("num-commits") 32 | 33 | // Create a channel to send work to the workers with a buffer size of length gitRepos 34 | // HINT: The buffer size specifies how many elements the channel can hold before blocking sends 35 | jobs := make(chan string, len(gitRepos)) 36 | 37 | // Create a channel to indicate when the go routines have finished 38 | done := make(chan bool) 39 | 40 | // Iterate over the numWorkers 41 | for range numWorkers { 42 | go func() { 43 | for repo := range jobs { 44 | utils.PrintGitLog(repo, onelineFlag, numCommits) 45 | } 46 | done <- true 47 | }() 48 | } 49 | // Send each git repository path to the jobs channel 50 | for _, repo := range gitRepos { 51 | jobs <- repo 52 | } 53 | close(jobs) // Close channel to signal no more work will be sent 54 | 55 | // wait for all goroutines to finish 56 | for range numWorkers { 57 | <-done 58 | } 59 | }, 60 | } 61 | 62 | func init() { 63 | rootCmd.AddCommand(logCmd) 64 | logCmd.Flags().IntP("workers", "w", 8, "Number of concurrent workers to use") 65 | logCmd.Flags().IntP("num-commits", "n", 4, "Show only the last n commits") 66 | logCmd.Flags().BoolP("oneline", "l", false, "Show short version of logs") 67 | } 68 | -------------------------------------------------------------------------------- /cmd/pull.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Erick Kramer 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "ripvcs/utils" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // pullCmd represents the pull command 13 | var pullCmd = &cobra.Command{ 14 | Use: "pull ", 15 | Short: "Pull latest version from remote.", 16 | Long: `Pull latest version from remote. 17 | 18 | Update all repositories found relative to the given path or to the current path.`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | var root string 21 | if len(args) == 0 { 22 | root = "." 23 | } else { 24 | root = utils.GetRepoPath(args[0]) 25 | } 26 | gitRepos := utils.FindGitRepositories(root) 27 | 28 | numWorkers, _ := cmd.Flags().GetInt("workers") 29 | 30 | // Create a channel to send work to the workers with a buffer size of length gitRepos 31 | // HINT: The buffer size specifies how many elements the channel can hold before blocking sends 32 | jobs := make(chan string, len(gitRepos)) 33 | 34 | // Create a channel to indicate when the go routines have finished 35 | done := make(chan bool) 36 | 37 | // Iterate over the numWorkers 38 | for range numWorkers { 39 | go func() { 40 | for repo := range jobs { 41 | utils.PrintGitPull(repo) 42 | } 43 | done <- true 44 | }() 45 | } 46 | // Send each git repository path to the jobs channel 47 | for _, repo := range gitRepos { 48 | jobs <- repo 49 | } 50 | close(jobs) // Close channel to signal no more work will be sent 51 | 52 | // wait for all goroutines to finish 53 | for range numWorkers { 54 | <-done 55 | } 56 | }, 57 | } 58 | 59 | func init() { 60 | rootCmd.AddCommand(pullCmd) 61 | pullCmd.Flags().IntP("workers", "w", 8, "Number of concurrent workers to use") 62 | } 63 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Erick Kramer 3 | */ 4 | 5 | package cmd 6 | 7 | import ( 8 | "os" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // rootCmd represents the base command when called without any subcommands 14 | var rootCmd = &cobra.Command{ 15 | Use: "rv", 16 | Short: "Fast CLI tool for managing multiple Git repositories.", 17 | Long: ``, 18 | } 19 | 20 | // Execute adds all child commands to the root command and sets flags appropriately. 21 | // This is called by main.main(). It only needs to happen once to the rootCmd. 22 | func Execute() { 23 | err := rootCmd.Execute() 24 | if err != nil { 25 | os.Exit(1) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cmd/status.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Erick Kramer 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "ripvcs/utils" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // statusCmd represents the status command 13 | var statusCmd = &cobra.Command{ 14 | Use: "status ", 15 | Short: "Check status of all repositories", 16 | Long: `Check status of all repositories. 17 | 18 | If no path is given, it checks the status of any Git repository relative to the current path.`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | var root string 21 | if len(args) == 0 { 22 | root = "." 23 | } else { 24 | root = utils.GetRepoPath(args[0]) 25 | } 26 | gitRepos := utils.FindGitRepositories(root) 27 | 28 | plainStatus, _ := cmd.Flags().GetBool("plain") 29 | skipEmtpy, _ := cmd.Flags().GetBool("skip-empty") 30 | numWorkers, _ := cmd.Flags().GetInt("workers") 31 | 32 | // Create a channel to send work to the workers with a buffer size of length gitRepos 33 | jobs := make(chan string, len(gitRepos)) 34 | 35 | // Create a channel to indicate when the go routines have finished 36 | done := make(chan bool) 37 | 38 | // Iterate over the numWorkers 39 | for range numWorkers { 40 | go func() { 41 | for repo := range jobs { 42 | utils.PrintGitStatus(repo, skipEmtpy, plainStatus) 43 | } 44 | done <- true 45 | }() 46 | } 47 | // Send each git repository path to the jobs channel 48 | for _, repo := range gitRepos { 49 | jobs <- repo 50 | } 51 | close(jobs) // Close channel to signal no more work will be sent 52 | 53 | // wait for all goroutines to finish 54 | for range numWorkers { 55 | <-done 56 | } 57 | }, 58 | } 59 | 60 | func init() { 61 | rootCmd.AddCommand(statusCmd) 62 | statusCmd.Flags().IntP("workers", "w", 8, "Number of concurrent workers to use") 63 | statusCmd.Flags().BoolP("plain", "p", false, "Show simpler status report") 64 | statusCmd.Flags().BoolP("skip-empty", "s", false, "Skip repositories with clean working tree.") 65 | } 66 | -------------------------------------------------------------------------------- /cmd/switch.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Erick Kramer 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "ripvcs/utils" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // switchCmd represents the switch command 15 | var switchCmd = &cobra.Command{ 16 | Use: "switch ", 17 | Short: "Switch repository version", 18 | Long: `Switch repository version. 19 | 20 | It allows to easily run Git switch operation on the given repository.`, 21 | Run: func(cmd *cobra.Command, args []string) { 22 | // var repoName string 23 | if len(args) == 0 { 24 | utils.PrintErrorMsg("Repository Name or Path not given\n") 25 | os.Exit(1) 26 | } 27 | repoPath := utils.GetRepoPath(args[0]) 28 | 29 | if !utils.IsGitRepository(repoPath) { 30 | fmt.Println("Directory given is not a git repository") 31 | os.Exit(1) 32 | } 33 | createBranch, _ := cmd.Flags().GetBool("create") 34 | detachHead, _ := cmd.Flags().GetBool("detach") 35 | branch, _ := cmd.Flags().GetString("branch") 36 | utils.PrintGitSwitch(repoPath, branch, createBranch, detachHead) 37 | }, 38 | } 39 | 40 | func init() { 41 | rootCmd.AddCommand(switchCmd) 42 | switchCmd.Flags().BoolP("create", "c", false, "Create and switch to a new branch") 43 | switchCmd.Flags().BoolP("detach", "d", false, "Detach HEAD at named commit or tag") 44 | switchCmd.Flags().StringP("branch", "b", "", "Version (branch, commit, or tag) to switch to") 45 | } 46 | -------------------------------------------------------------------------------- /cmd/sync.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Erick Kramer 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "ripvcs/utils" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // syncCmd represents the pull command 13 | var syncCmd = &cobra.Command{ 14 | Use: "sync ", 15 | Short: "Synchronize all found repositories.", 16 | Long: `Synchronize all found repositories. 17 | 18 | It stashes all changes found in the repostory, pull latest remote, 19 | and bring back staged changes.`, 20 | Run: func(cmd *cobra.Command, args []string) { 21 | var root string 22 | if len(args) == 0 { 23 | root = "." 24 | } else { 25 | root = utils.GetRepoPath(args[0]) 26 | } 27 | gitRepos := utils.FindGitRepositories(root) 28 | 29 | numWorkers, _ := cmd.Flags().GetInt("workers") 30 | 31 | // Create a channel to send work to the workers with a buffer size of length gitRepos 32 | // HINT: The buffer size specifies how many elements the channel can hold before blocking sends 33 | jobs := make(chan string, len(gitRepos)) 34 | 35 | // Create a channel to indicate when the go routines have finished 36 | done := make(chan bool) 37 | 38 | // Iterate over the numWorkers 39 | for range numWorkers { 40 | go func() { 41 | for repo := range jobs { 42 | utils.PrintGitSync(repo) 43 | } 44 | done <- true 45 | }() 46 | } 47 | // Send each git repository path to the jobs channel 48 | for _, repo := range gitRepos { 49 | jobs <- repo 50 | } 51 | close(jobs) // Close channel to signal no more work will be sent 52 | 53 | // wait for all goroutines to finish 54 | for range numWorkers { 55 | <-done 56 | } 57 | }, 58 | } 59 | 60 | func init() { 61 | rootCmd.AddCommand(syncCmd) 62 | syncCmd.Flags().IntP("workers", "w", 8, "Number of concurrent workers to use") 63 | } 64 | -------------------------------------------------------------------------------- /cmd/validate.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Erick Kramer 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "ripvcs/utils" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // validateCmd represents the validate command 15 | var validateCmd = &cobra.Command{ 16 | Use: "validate <.repos file>", 17 | Short: "Validate a .repos file", 18 | Long: `Validate a .repos file. 19 | 20 | It checks that all the repositories in the given file have a reachable Git URL 21 | and that the provided version exist.`, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | if len(args) == 0 { 24 | fmt.Println("Error: Repos file not given!") 25 | os.Exit(1) 26 | } 27 | filePath := args[0] 28 | 29 | // Check that a valid file was given 30 | config, err := utils.ParseReposFile(filePath) 31 | if err != nil { 32 | fmt.Printf("Invalid file given {%s}. %s\n", filePath, err) 33 | os.Exit(1) 34 | } 35 | 36 | numWorkers, _ := cmd.Flags().GetInt("workers") 37 | // Create a channel to send work to the workers with a buffer size of length gitRepos 38 | jobs := make(chan utils.RepositoryJob, len(config.Repositories)) 39 | // Create channel to collect results 40 | results := make(chan bool, len(config.Repositories)) 41 | // Create a channel to indicate when the go routines have finished 42 | done := make(chan bool) 43 | 44 | for range numWorkers { 45 | go func() { 46 | for job := range jobs { 47 | if job.Repo.Type != "git" { 48 | utils.PrintRepoEntry(job.RepoPath, "") 49 | utils.PrintErrorMsg(fmt.Sprintf("Unsupported repository type %s.\n", job.Repo.Type)) 50 | results <- false 51 | } else { 52 | success := utils.PrintCheckGit(job.RepoPath, job.Repo.URL, job.Repo.Version, false) 53 | results <- success 54 | } 55 | } 56 | done <- true 57 | }() 58 | } 59 | 60 | for key, repo := range config.Repositories { 61 | jobs <- utils.RepositoryJob{RepoPath: key, Repo: repo} 62 | } 63 | close(jobs) 64 | // wait for all goroutines to finish 65 | for range numWorkers { 66 | <-done 67 | } 68 | close(results) 69 | for result := range results { 70 | if !result { 71 | os.Exit(1) 72 | } 73 | } 74 | }, 75 | } 76 | 77 | func init() { 78 | rootCmd.AddCommand(validateCmd) 79 | validateCmd.Flags().IntP("workers", "w", 8, "Number of concurrent workers to use") 80 | } 81 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Erick Kramer 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "ripvcs/utils" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var ( 14 | // These variables should be set using ldflags during the build 15 | Version = "v1.0.2" 16 | Commit = "071f3a7" 17 | BuildDate = "22.05.2025" 18 | ) 19 | 20 | // versionCmd represents the version command 21 | var versionCmd = &cobra.Command{ 22 | Use: "version", 23 | Short: "Print the version number", 24 | Run: func(cmd *cobra.Command, args []string) { 25 | utils.PrintSection("ripvcs (rv)") 26 | utils.PrintSeparator() 27 | fmt.Printf("%sVersion:%s %s\n", utils.BlueColor, utils.ResetColor, Version) 28 | fmt.Printf("%sCommit:%s %s\n", utils.BlueColor, utils.ResetColor, Commit) 29 | fmt.Printf("%sBuild Date:%s %s\n", utils.BlueColor, utils.ResetColor, BuildDate) 30 | utils.PrintSeparator() 31 | }, 32 | } 33 | 34 | func init() { 35 | rootCmd.AddCommand(versionCmd) 36 | } 37 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module ripvcs 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/jesseduffield/yaml v2.1.0+incompatible 7 | github.com/spf13/cobra v1.8.0 8 | gopkg.in/yaml.v2 v2.4.0 9 | ) 10 | 11 | require ( 12 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 13 | github.com/spf13/pflag v1.0.5 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 3 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 4 | github.com/jesseduffield/yaml v2.1.0+incompatible h1:HWQJ1gIv2zHKbDYNp0Jwjlj24K8aqpFHnMCynY1EpmE= 5 | github.com/jesseduffield/yaml v2.1.0+incompatible/go.mod h1:w0xGhOSIJCGYYW+hnFPTutCy5aACpkcwbmORt5axGqk= 6 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 7 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 8 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 9 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 10 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 14 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 15 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 16 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 NAME HERE 3 | */ 4 | package main 5 | 6 | import "ripvcs/cmd" 7 | 8 | func main() { 9 | cmd.Execute() 10 | } 11 | -------------------------------------------------------------------------------- /test/git_helpers_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "ripvcs/utils" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestMain(m *testing.M) { 12 | // Create temporary directories and files for testing 13 | createTestFiles() 14 | 15 | // Run tests 16 | exitVal := m.Run() 17 | 18 | cleanupTestFiles() 19 | 20 | // Exit with the appropriate exit code 21 | os.Exit(exitVal) 22 | } 23 | 24 | func createTestFiles() { 25 | // Create root directory 26 | path := "/tmp/testdata/valid_repo/" 27 | err := os.MkdirAll(path, 0755) 28 | if err != nil { 29 | panic(err) 30 | } 31 | cmd := exec.Command("git", "init") 32 | cmd.Dir = path 33 | _, err = cmd.CombinedOutput() 34 | if err != nil { 35 | panic(err) 36 | } 37 | 38 | // Create nested directories and .git repository 39 | path = "/tmp/testdata/normal_dir/another_repo/" 40 | err = os.MkdirAll(path, 0755) 41 | if err != nil { 42 | panic(err) 43 | } 44 | cmd = exec.Command("git", "init") 45 | cmd.Dir = path 46 | _, err = cmd.CombinedOutput() 47 | if err != nil { 48 | panic(err) 49 | } 50 | } 51 | 52 | func cleanupTestFiles() { 53 | err := os.RemoveAll("/tmp/testdata") 54 | if err != nil { 55 | panic(err) 56 | } 57 | } 58 | 59 | func TestIsGitRepository(t *testing.T) { 60 | if !utils.IsGitRepository("/tmp/testdata/valid_repo") { 61 | t.Errorf("Expected ./valid_repo to be a Git repository") 62 | } 63 | 64 | if utils.IsGitRepository("/tmp/testdata/normal_dir") { 65 | t.Errorf("Expected ./normal_dir to not be a Git repository") 66 | } 67 | } 68 | 69 | func TestFindGitRepos(t *testing.T) { 70 | foundRepos := utils.FindGitRepositories("/tmp/testdata") 71 | if len(foundRepos) != 2 { 72 | t.Errorf("Expected two git repositories relative to this test file, but got %v", len(foundRepos)) 73 | } 74 | } 75 | 76 | func TestGitStatus(t *testing.T) { 77 | if utils.GetGitStatus("/tmp/testdata/valid_repo", false) == "" { 78 | t.Errorf("Failed to check status of a valid repository") 79 | } 80 | if utils.GetGitStatus("/tmp/testdata/valid_repo", true) == "" { 81 | t.Errorf("Failed to check status of a valid repository") 82 | } 83 | } 84 | 85 | func TestGetGitBranch(t *testing.T) { 86 | testingBranch := "jazzy" 87 | repoPath := "/tmp/testdata/demos_branch" 88 | if utils.GitClone("https://github.com/ros2/demos.git", testingBranch, repoPath, true, false, false, false) != utils.SuccessfullClone { 89 | t.Errorf("Expected to successfully clone git repository") 90 | } 91 | if utils.GetGitBranch(repoPath) != testingBranch { 92 | t.Errorf("Failed to get main branch for valid git repository") 93 | } 94 | testingTag := "0.34.0" 95 | if utils.GitClone("https://github.com/ros2/demos.git", testingTag, repoPath, true, false, false, false) != utils.SuccessfullClone { 96 | t.Errorf("Expected to successfully clone git repository") 97 | } 98 | obtainedTag := utils.GetGitBranch(repoPath) 99 | if obtainedTag != testingTag { 100 | t.Errorf("Failed to get tag for the cloned repository. Got %s", obtainedTag) 101 | } 102 | } 103 | 104 | func TestGetGitCommitSha(t *testing.T) { 105 | testingSha := "839b622bc40ec62307d6ba0615adb9b8bd1cbc30" 106 | repoPath := "/tmp/testdata/demos_sha" 107 | if utils.GitClone("https://github.com/ros2/demos.git", testingSha, repoPath, false, false, false, false) != utils.SuccessfullClone { 108 | t.Errorf("Expected to successfully clone git repository") 109 | } 110 | if utils.GetGitCommitSha(repoPath) != testingSha { 111 | t.Errorf("Failed to get commit sha of the cloned git repository") 112 | } 113 | } 114 | 115 | func TestGetGitRemoteURL(t *testing.T) { 116 | repoPath := "/tmp/testdata/demos_url" 117 | remoteUrl := "https://github.com/ros2/demos.git" 118 | if utils.GitClone(remoteUrl, "", repoPath, false, false, false, false) != utils.SuccessfullClone { 119 | t.Errorf("Expected to successfully clone git repository") 120 | } 121 | if utils.GetGitRemoteURL(repoPath) != remoteUrl { 122 | t.Errorf("Failed to get remote URL for the git repository") 123 | } 124 | } 125 | 126 | func TestGitPull(t *testing.T) { 127 | repoPath := "/tmp/testdata/demos_pull" 128 | if utils.GitClone("https://github.com/ros2/demos.git", "rolling", repoPath, false, false, false, false) != utils.SuccessfullClone { 129 | t.Errorf("Expected to successfully clone git repository") 130 | } 131 | msg := utils.PullGitRepo(repoPath) 132 | if strings.TrimSpace(msg) != "Already up to date." { 133 | t.Errorf("Failed to pull valid repository. Obtained %s", msg) 134 | } 135 | } 136 | 137 | func TestGitLog(t *testing.T) { 138 | if utils.GetGitLog(".", false, 5) == "" { 139 | t.Errorf("Failed to check logs of a valid repository") 140 | } 141 | 142 | if utils.GetGitLog(".", true, 5) == "" { 143 | t.Errorf("Failed to check logs of a valid repository") 144 | } 145 | } 146 | 147 | func TestCheckGitUrl(t *testing.T) { 148 | if valid, err := utils.IsGitURLValid("https://github.com/ros2/demosasdasd.git", "rolling", false); valid { 149 | t.Errorf("Expected to return invalid URL. Error %v", err) 150 | } 151 | if valid, err := utils.IsGitURLValid("https://github.com/ros2/demos.git", "rolling", false); !valid { 152 | t.Errorf("Expected to return valid URL given a branch. Error %v", err) 153 | } 154 | if valid, err := utils.IsGitURLValid("https://github.com/ros2/demos.git", "", false); !valid { 155 | t.Errorf("Expected to return valid URL given no branch. Error %v", err) 156 | } 157 | if valid, err := utils.IsGitURLValid("https://github.com/ros2/demos.git", "0.34.0", false); !valid { 158 | t.Errorf("Expected to return valid URL given a tag. Error %v", err) 159 | } 160 | if valid, err := utils.IsGitURLValid("https://github.com/ros2/demos.git", "839b622bc40ec62307d6ba0615adb9b8bd1cbc30", false); valid { 161 | t.Errorf("Expected to return invalid URL given a commit SHA. Error %v", err) 162 | } 163 | } 164 | 165 | func TestIsValidCommitSha(t *testing.T) { 166 | if !utils.IsValidSha("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391") { 167 | t.Errorf("Expected to return valid SHA") 168 | } 169 | if !utils.IsValidSha("839b622") { 170 | t.Errorf("Expected to return valid SHA") 171 | } 172 | if utils.IsValidSha("INVALIDSHA123456789012345678901234567890") { 173 | t.Errorf("Expected to return invalid SHA") 174 | } 175 | if utils.IsValidSha("11111111111111111111111111111111111111111") { 176 | t.Errorf("Expected to return invalid SHA. Invalid length (41 chars)") 177 | } 178 | if utils.IsValidSha("") { 179 | t.Errorf("Expected to return invalid SHA. Invalid length (0 chars)") 180 | } 181 | if utils.IsValidSha("999999") { 182 | t.Errorf("Expected to return invalid SHA. Invalid length (6 chars)") 183 | } 184 | } 185 | 186 | func TestCloneGitRepo(t *testing.T) { 187 | repoPath := "/tmp/testdata/demos_clone" 188 | if utils.GitClone("https://github.com/ros2/demos.git", "rolling", repoPath, false, false, false, false) != utils.SuccessfullClone { 189 | t.Errorf("Expected to successfully clone git repository") 190 | } 191 | if utils.GitClone("https://github.com/ros2/ros2cli", "", "/tmp/testdata/ros2cli", false, false, false, false) != utils.SuccessfullClone { 192 | t.Errorf("Expected to successfully clone git repository") 193 | } 194 | if utils.GitClone("https://github.com/ros2/sadasdasd.git", "", "/tmp/testdata/sdasda", false, false, false, false) != utils.FailedClone { 195 | t.Errorf("Expected to fail to clone git repository") 196 | } 197 | if utils.GitClone("https://github.com/ros2/demos.git", "rolling", repoPath, false, false, false, false) != utils.SkippedClone { 198 | t.Errorf("Expected to skip to clone git repository") 199 | } 200 | if utils.GitClone("https://github.com/ros2/demos.git", "", repoPath, true, false, false, false) != utils.SuccessfullClone { 201 | t.Errorf("Expected to overwrite found git repository") 202 | } 203 | if utils.GitClone("https://github.com/ros2/demos.git", "jazzy", repoPath, false, false, false, false) != utils.SwitchedBranch { 204 | t.Errorf("Expected to successfully to switch to a branch") 205 | } 206 | if utils.GitClone("https://github.com/ros2/demos.git", "", repoPath, true, true, false, false) != utils.SuccessfullClone { 207 | t.Errorf("Expected to successfully to clone git repository with shallow enabled") 208 | } 209 | if utils.GitClone("https://github.com/cyberbotics/webots_ros2.git", "", "/tmp/testdata/webots_ros2", false, false, false, true) != utils.SuccessfullClone { 210 | t.Errorf("Expected to successfully to clone git repository with submodules") 211 | } 212 | if utils.GitClone("https://github.com/cyberbotics/webots_ros2.git", "", "/tmp/testdata/webots_ros2", true, true, false, true) != utils.SuccessfullClone { 213 | t.Errorf("Expected to successfully to clone git repository with submodules and shallow enabled") 214 | } 215 | count, err := utils.RunGitCmd(repoPath, "rev-list", nil, []string{"--all", "--count"}...) 216 | if err != nil || strings.TrimSpace(count) != "1" { 217 | t.Errorf("Expected to have a shallow clone of the git repository") 218 | } 219 | 220 | testingSha := "839b622bc40ec62307d6ba0615adb9b8bd1cbc30" 221 | if utils.GitClone("https://github.com/ros2/demos.git", testingSha, repoPath, true, false, false, false) != utils.SuccessfullClone { 222 | t.Errorf("Expected to successfully clone git repository given a SHA") 223 | } 224 | } 225 | 226 | func TestGitSwitch(t *testing.T) { 227 | repoPath := "/tmp/testdata/switch_test" 228 | if utils.GitClone("https://github.com/ros2/demos.git", "rolling", repoPath, false, false, false, false) != utils.SuccessfullClone { 229 | t.Errorf("Expected to successfully clone git repository") 230 | } 231 | _, err := utils.GitSwitch(repoPath, "humble", false, false) 232 | if err != nil { 233 | t.Errorf("Expected to successfully to switch to a branch. Error %s", err) 234 | } 235 | 236 | _, err = utils.GitSwitch(repoPath, "nonexisting", false, false) 237 | if err == nil { 238 | t.Errorf("Expected to fail to switch to a nonexisting branch.\nError %s", err) 239 | } 240 | _, err = utils.GitSwitch(repoPath, "nonexisting", true, false) 241 | if err != nil { 242 | t.Errorf("Expected to successfully to create a new branch.\nError %s", err) 243 | } 244 | _, err = utils.GitSwitch(repoPath, "0.34.0", false, true) 245 | if err != nil { 246 | t.Errorf("Expected to successfully to switch to a tag.\nError %s", err) 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /test/invalid_example.repos: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErickKramer/ripvcs/fd4531a01baee3fbbf2864211c6f0c7d3bfd5a03/test/invalid_example.repos -------------------------------------------------------------------------------- /test/nested_example.repos: -------------------------------------------------------------------------------- 1 | repositories: 2 | naoqi_driver2: 3 | type: git 4 | url: https://github.com/ros-naoqi/naoqi_driver2 5 | version: main 6 | twist_mux: 7 | type: git 8 | url: https://github.com/ros-teleop/twist_mux.git 9 | version: humble 10 | exclude: [twist_mux_deps.repos] 11 | twist_mux_2/stuff: 12 | type: git 13 | url: https://github.com/ros-teleop/twist_mux.git 14 | version: humble 15 | exclude: [stuff] 16 | 17 | -------------------------------------------------------------------------------- /test/repos_helpers_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "ripvcs/utils" 7 | "testing" 8 | ) 9 | 10 | func TestReposFile(t *testing.T) { 11 | err := os.WriteFile("/tmp/wrong_extension.txt", []byte{}, 0644) 12 | if err != nil { 13 | panic(err) 14 | } 15 | errValid := utils.IsReposFileValid("/tmp/wrong_extension.txt") 16 | if errValid == nil { 17 | t.Errorf("Expected to report file with wrong extension") 18 | } 19 | errValid = utils.IsReposFileValid("missing_file.repos") 20 | if errValid == nil { 21 | t.Errorf("Expected to report non-existing file") 22 | } 23 | errValid = utils.IsReposFileValid("./valid_example.repos") 24 | if errValid != nil { 25 | t.Errorf("Expected to report a valid file") 26 | } 27 | } 28 | 29 | func TestParsingReposFile(t *testing.T) { 30 | repo, err := utils.ParseReposFile("./valid_example.repos") 31 | if err != nil { 32 | t.Errorf("Expected to parse .repos file") 33 | } 34 | if len(repo.Repositories) != 8 { 35 | t.Errorf("The parsed repositories from .repos file do not match the expected values.") 36 | } 37 | expectedType := "git" 38 | if repoType := repo.Repositories["demos_rolling"].Type; repoType != expectedType { 39 | t.Errorf("Expected to have %s as repo type. Got: %s", expectedType, repoType) 40 | } 41 | expectedUrl := "https://github.com/ros2/demos.git" 42 | if repoUrl := repo.Repositories["demos_rolling"].URL; repoUrl != expectedUrl { 43 | t.Errorf("Expected to have %s as repo url. Got: %s", expectedUrl, repoUrl) 44 | } 45 | expectedVersion := "rolling" 46 | if repoVersion := repo.Repositories["demos_rolling"].Version; repoVersion != expectedVersion { 47 | t.Errorf("Expected to have %s as repo version. Got: %s", expectedVersion, repoVersion) 48 | } 49 | repo, err = utils.ParseReposFile("./valid_example.rosinstall") 50 | if err != nil { 51 | t.Errorf("Expected to parse .rosinstall file") 52 | } 53 | if len(repo.Repositories) != 2 { 54 | t.Errorf("The parsed repositories from .rosinstall do not match the expected values.") 55 | } 56 | expectedType = "git" 57 | if repoType := repo.Repositories["moveit_msgs"].Type; repoType != expectedType { 58 | t.Errorf("Expected to have %s as repo type. Got: %s", expectedType, repoType) 59 | } 60 | expectedUrl = "https://github.com/moveit/moveit_msgs.git" 61 | if repoUrl := repo.Repositories["moveit_msgs"].URL; repoUrl != expectedUrl { 62 | t.Errorf("Expected to have %s as repo url. Got: %s", expectedUrl, repoUrl) 63 | } 64 | expectedVersion = "master" 65 | if repoVersion := repo.Repositories["moveit_msgs"].Version; repoVersion != expectedVersion { 66 | t.Errorf("Expected to have %s as repo version. Got: %s", expectedVersion, repoVersion) 67 | } 68 | 69 | err = os.WriteFile("/tmp/empty_file.repos", []byte{}, 0644) 70 | if err != nil { 71 | panic(err) 72 | } 73 | _, err = utils.ParseReposFile("/tmp/empty_file.repos") 74 | if err == nil { 75 | t.Errorf("Expected to report empty file") 76 | } 77 | err = os.RemoveAll("/tmp/empty_file.repos") 78 | if err != nil { 79 | panic(err) 80 | } 81 | } 82 | 83 | func TestFindingReposFiles(t *testing.T) { 84 | foundReposFiles, err := utils.FindReposFiles(".", nil) 85 | 86 | if err != nil || len(foundReposFiles) == 0 { 87 | t.Errorf("Expected to find at least one .repos file %v", err) 88 | } 89 | foundReposFiles, err = utils.FindReposFiles("../cmd/", nil) 90 | 91 | if err != nil || len(foundReposFiles) != 0 { 92 | t.Errorf("Expected to not find any .repos file %v", err) 93 | } 94 | 95 | foundReposFiles, err = utils.FindReposFiles(".", []string{"../test"}) 96 | if err != nil || len(foundReposFiles) == 0 { 97 | t.Errorf("Expected to find at least one .repos file %v", err) 98 | } 99 | } 100 | 101 | func TestFindDirectory(t *testing.T) { 102 | // Create dummy dir 103 | path := "/tmp/testdata/valid_repo/" 104 | err := os.MkdirAll(path, 0755) 105 | if err != nil { 106 | panic(err) 107 | } 108 | 109 | repoPath, err := utils.FindDirectory("/tmp/testdata", "valid_repo") 110 | if err != nil { 111 | t.Errorf("Expected to find directory %v", err) 112 | } 113 | if filepath.Clean(repoPath) != filepath.Clean(path) { 114 | t.Errorf("Wrong directory found. Expected %v, found %v", path, repoPath) 115 | } 116 | 117 | _, err = utils.FindDirectory("", "sadsd") 118 | if err == nil { 119 | t.Errorf("Expected to failed to find directory, based on empty rootPath") 120 | } 121 | _, err = utils.FindDirectory("/tmp", "") 122 | if err == nil { 123 | t.Errorf("Expected to failed to find directory, based on empty targetDir") 124 | } 125 | _, err = utils.FindDirectory("/sdasd", "") 126 | if err == nil { 127 | t.Errorf("Expected to failed to find directory, based on nonexisting rootPath") 128 | } 129 | _, err = utils.FindDirectory("/tmp", "/tmp/testdata/") 130 | if err == nil { 131 | t.Errorf("Expected to failed to find directory, targetDir being a path") 132 | } 133 | err = os.RemoveAll("/tmp/testdata") 134 | if err != nil { 135 | panic(err) 136 | } 137 | } 138 | 139 | func TestParseRepositoryInfo(t *testing.T) { 140 | repository := utils.ParseRepositoryInfo("", false) 141 | if repository.Type != "" || repository.Version != "" || repository.URL != "" { 142 | t.Errorf("Expected to get an empty repository object") 143 | } 144 | repoPath := "/tmp/testdata/demos_parse" 145 | repoURL := "https://github.com/ros2/demos.git" 146 | repoVersion := "rolling" 147 | if utils.GitClone(repoURL, repoVersion, repoPath, true, false, false, false) != utils.SuccessfullClone { 148 | t.Errorf("Expected to successfully clone git repository") 149 | } 150 | 151 | repository = utils.ParseRepositoryInfo(repoPath, false) 152 | if repository.Type != "git" || repository.Version != repoVersion || repository.URL != repoURL { 153 | t.Errorf("Failed to properly parse the repository info using branch") 154 | } 155 | 156 | repoVersion = "839b622bc40ec62307d6ba0615adb9b8bd1cbc30" 157 | if utils.GitClone(repoURL, repoVersion, repoPath, true, false, false, false) != utils.SuccessfullClone { 158 | t.Errorf("Expected to successfully clone git repository") 159 | } 160 | repository = utils.ParseRepositoryInfo(repoPath, true) 161 | if repository.Type != "git" || repository.Version != repoVersion || repository.URL != repoURL { 162 | t.Errorf("Failed to properly parse the repository info using commit") 163 | } 164 | 165 | repoVersion = "0.34.0" 166 | if utils.GitClone(repoURL, repoVersion, repoPath, true, false, false, false) != utils.SuccessfullClone { 167 | t.Errorf("Expected to successfully clone git repository") 168 | } 169 | repository = utils.ParseRepositoryInfo(repoPath, false) 170 | if repository.Type != "git" || repository.Version != repoVersion || repository.URL != repoURL { 171 | t.Errorf("Failed to properly parse the repository info using tag") 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /test/valid_example.repos: -------------------------------------------------------------------------------- 1 | repositories: 2 | demos_rolling: 3 | type: git 4 | url: https://github.com/ros2/demos.git 5 | version: rolling 6 | demos_rolling_2: 7 | type: git 8 | url: https://github.com/ros2/demos.git 9 | version: rolling 10 | demos_rolling_3: 11 | type: git 12 | url: https://github.com/ros2/demos.git 13 | version: rolling 14 | demos_rolling_4: 15 | type: git 16 | url: https://github.com/ros2/demos.git 17 | version: rolling 18 | demos_rolling_5: 19 | type: git 20 | url: https://github.com/ros2/demos.git 21 | version: rolling 22 | dummy/demos_rolling_5: 23 | type: git 24 | url: https://github.com/ros2/demos.git 25 | version: rolling 26 | dummy/demos_rolling: 27 | type: git 28 | url: https://github.com/ros2/demos.git 29 | version: rolling 30 | demos_humble: 31 | type: git 32 | url: https://github.com/ros2/demos.git 33 | version: humble 34 | -------------------------------------------------------------------------------- /test/valid_example.rosinstall: -------------------------------------------------------------------------------- 1 | - git: 2 | local-name: moveit_msgs 3 | uri: https://github.com/moveit/moveit_msgs.git 4 | version: master 5 | - git: 6 | local-name: moveit_resources 7 | uri: https://github.com/moveit/moveit_resources.git 8 | version: master 9 | -------------------------------------------------------------------------------- /utils/git_helpers.go: -------------------------------------------------------------------------------- 1 | // utils/git_helpers.go 2 | 3 | package utils 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | // Create constant Error messages 16 | const ( 17 | SuccessfullClone = iota 18 | SkippedClone 19 | FailedClone 20 | SwitchedBranch 21 | ) 22 | 23 | // IsGitRepository checks if a directory is a git repository 24 | func IsGitRepository(dir string) bool { 25 | gitDir := filepath.Join(dir, ".git") 26 | _, err := os.Stat(gitDir) 27 | return err == nil 28 | } 29 | 30 | // FindGitRepositories Get a slice of all the found git repositories at the given root 31 | func FindGitRepositories(root string) []string { 32 | var gitRepos []string 33 | 34 | // Use an anonymous function to check each file found relative to the given root 35 | err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 36 | if err != nil { 37 | return err // Return any error encountered during walking 38 | } 39 | if info.IsDir() && IsGitRepository(path) { 40 | gitRepos = append(gitRepos, path) 41 | } 42 | return nil // Continue walking 43 | }) 44 | if err != nil { 45 | PrintErrorMsg(fmt.Sprintf("Error: %s", err)) 46 | } 47 | return gitRepos 48 | } 49 | 50 | // RunGitCmd Helper method to execute a git command 51 | func RunGitCmd(path string, gitCmd string, envConfig []string, args ...string) (string, error) { 52 | cmdArgs := append([]string{"-c", "color.ui=always", gitCmd}, args...) 53 | cmd := exec.Command("git", cmdArgs...) 54 | cmd.Env = append(os.Environ(), envConfig...) 55 | cmd.Dir = path 56 | 57 | output, err := cmd.CombinedOutput() 58 | if err != nil { 59 | return "", err 60 | } 61 | return string(output), nil 62 | } 63 | 64 | // GetGitStatus Execute git status in a given path 65 | func GetGitStatus(path string, plainStatus bool) string { 66 | var statusArgs []string 67 | if plainStatus { 68 | statusArgs = []string{"-sb"} 69 | } 70 | output, err := RunGitCmd(path, "status", nil, statusArgs...) 71 | if err != nil { 72 | PrintErrorMsg(fmt.Sprintf("Failed to check Git status of %s. Error: %s", path, err)) 73 | } 74 | return output 75 | } 76 | 77 | // GetGitBranch Get current git branch in a given path 78 | func GetGitBranch(path string) string { 79 | output, err := RunGitCmd(path, "branch", nil, "--show-current") 80 | if err != nil { 81 | PrintErrorMsg(fmt.Sprintf("Failed to get current Git branch of %s. Error: %s", path, err)) 82 | } 83 | if output != "" { 84 | return strings.TrimSpace(output) 85 | } 86 | checkTagArgs := []string{"--points-at", "HEAD"} 87 | output, err = RunGitCmd(path, "tag", nil, checkTagArgs...) 88 | if err != nil { 89 | PrintErrorMsg(fmt.Sprintf("Failed to get current Git branch of %s. Error: %s", path, err)) 90 | } 91 | return strings.TrimSpace(output) 92 | } 93 | 94 | func GetGitCommitSha(path string) string { 95 | cmdArgs := []string{"--verify", "HEAD"} 96 | output, err := RunGitCmd(path, "rev-parse", nil, cmdArgs...) 97 | if err != nil { 98 | PrintErrorMsg(fmt.Sprintf("Failed to get current Git commit of %s. Error: %s", path, err)) 99 | } 100 | return strings.TrimSpace(output) 101 | } 102 | 103 | func GetGitRemoteURL(path string) string { 104 | cmdArgs := []string{"get-url", "origin"} 105 | output, err := RunGitCmd(path, "remote", nil, cmdArgs...) 106 | if err != nil { 107 | PrintErrorMsg(fmt.Sprintf("Failed to get URL for the origin remote of %s. Error: %s", path, err)) 108 | } 109 | return strings.TrimSpace(output) 110 | } 111 | 112 | // PullGitRepo Execute git pull in a given path 113 | func PullGitRepo(path string) string { 114 | output, err := RunGitCmd(path, "pull", nil) 115 | if err != nil { 116 | PrintErrorMsg(fmt.Sprintf("Failed to pull Git repository %s. Error: %s", path, err)) 117 | } 118 | return output 119 | } 120 | 121 | // StashGitRepo Execute git stash in a given path 122 | func StashGitRepo(path string, stashCmd string) string { 123 | output, err := RunGitCmd(path, "stash", nil, []string{stashCmd}...) 124 | if err != nil { 125 | PrintErrorMsg(fmt.Sprintf("Failed to run stash with %s Git repository %s. Error: %s", stashCmd, path, err)) 126 | } 127 | return output 128 | } 129 | 130 | // SyncGitRepo Handle syncronization of a git repo 131 | func SyncGitRepo(path string) string { 132 | output := StashGitRepo(path, "push") 133 | output += PullGitRepo(path) 134 | if StashGitRepo(path, "list") != "" { 135 | output += StashGitRepo(path, "pop") 136 | } 137 | return output 138 | } 139 | 140 | // IsGitURLValid Check if a git URL is reachable 141 | func IsGitURLValid(url string, version string, enablePrompt bool) (bool, error) { 142 | var envConfig []string 143 | if enablePrompt { 144 | envConfig = []string{"GIT_TERMINAL_PROMPT=1"} 145 | } else { 146 | envConfig = []string{"GIT_TERMINAL_PROMPT=0"} 147 | } 148 | 149 | var urlArgs []string 150 | var output string 151 | var err error 152 | 153 | if IsValidSha(version) { 154 | err = fmt.Errorf("validation of URL given a commit SHA is currently not supported") 155 | } else { 156 | if version == "" { 157 | urlArgs = []string{url} 158 | } else { 159 | urlArgs = []string{url, version} 160 | } 161 | output, err = RunGitCmd(".", "ls-remote", envConfig, urlArgs...) 162 | } 163 | if err != nil || len(output) == 0 { 164 | return false, err 165 | } 166 | return true, nil 167 | } 168 | 169 | // GetGitLog Get logs for a given git repository 170 | func GetGitLog(path string, oneline bool, numCommits int) string { 171 | var cmdArgs []string 172 | 173 | if oneline { 174 | cmdArgs = []string{"-n", strconv.Itoa(numCommits), "--oneline"} 175 | } else { 176 | cmdArgs = []string{"-n", strconv.Itoa(numCommits)} 177 | } 178 | 179 | output, err := RunGitCmd(path, "log", nil, cmdArgs...) 180 | if err != nil { 181 | PrintErrorMsg(fmt.Sprintf("Failed to check Git log of %s. Error: %s", path, err)) 182 | } 183 | return output 184 | } 185 | 186 | // GitSwitch Switch version for a given git repository 187 | func GitSwitch(path string, branch string, createBranch bool, detachHead bool) (string, error) { 188 | 189 | cmdArgs := []string{} 190 | 191 | if detachHead { 192 | cmdArgs = append(cmdArgs, "--detach") 193 | } else if createBranch { 194 | cmdArgs = append(cmdArgs, "--create") 195 | } 196 | cmdArgs = append(cmdArgs, branch) 197 | 198 | output, err := RunGitCmd(path, "switch", nil, cmdArgs...) 199 | if err != nil { 200 | switchError := fmt.Errorf("failed to switch branch of repository %s to %s. Error: %s", path, branch, err) 201 | return "", switchError 202 | } 203 | return output, nil 204 | } 205 | 206 | // IsValidSha Check if sha given is a valid SHA1 207 | func IsValidSha(sha string) bool { 208 | shaRegex := regexp.MustCompile(`^[a-fA-F0-9]{7,40}$`) 209 | return shaRegex.MatchString(sha) 210 | 211 | } 212 | 213 | // GitClone Clone a given repository URL 214 | func GitClone(url string, version string, clonePath string, overwriteExisting bool, shallowClone bool, enablePrompt bool, recurseSubmodules bool) int { 215 | 216 | // Check if clonePath exists 217 | var skip_clone bool = false 218 | if _, err := os.Stat(clonePath); err == nil { 219 | if !overwriteExisting { 220 | skip_clone = true 221 | } else { 222 | // Remove existing clonePath 223 | if err := os.RemoveAll(clonePath); err != nil { 224 | PrintErrorMsg(fmt.Sprintf("Failed to remove existing cloning path %s. Error: %s\n", clonePath, err)) 225 | panic(err) 226 | } 227 | } 228 | } 229 | 230 | var envConfig []string 231 | if enablePrompt { 232 | envConfig = []string{"GIT_TERMINAL_PROMPT=1"} 233 | } else { 234 | envConfig = []string{"GIT_TERMINAL_PROMPT=0"} 235 | } 236 | 237 | var cmdArgs []string 238 | 239 | versionIsSha := IsValidSha(version) 240 | if version == "" || versionIsSha { 241 | cmdArgs = []string{url, clonePath} 242 | } else { 243 | cmdArgs = []string{url, "--branch", version, clonePath} 244 | } 245 | 246 | if shallowClone { 247 | cmdArgs = append(cmdArgs, "--depth", "1") 248 | } 249 | if recurseSubmodules { 250 | cmdArgs = append(cmdArgs, "--recurse-submodules") 251 | if shallowClone { 252 | cmdArgs = append(cmdArgs, "--shallow-submodules") 253 | } 254 | } 255 | if !skip_clone { 256 | if _, err := RunGitCmd(".", "clone", envConfig, cmdArgs...); err != nil { 257 | return FailedClone 258 | } 259 | } 260 | 261 | if skip_clone && (GetGitCommitSha(clonePath) == version || GetGitBranch(clonePath) == version) { 262 | return SkippedClone 263 | } 264 | 265 | if versionIsSha { 266 | if _, err := GitSwitch(clonePath, version, false, true); err != nil { 267 | return FailedClone 268 | } 269 | if skip_clone { 270 | return SwitchedBranch 271 | } 272 | } else if skip_clone { 273 | if _, err := GitSwitch(clonePath, version, false, false); err != nil { 274 | return FailedClone 275 | } 276 | return SwitchedBranch 277 | } 278 | 279 | return SuccessfullClone 280 | } 281 | 282 | // PrintGitLog Pretty print logs for a given git repository 283 | func PrintGitLog(path string, oneline bool, numCommits int) { 284 | repoLogs := GetGitLog(path, oneline, numCommits) 285 | PrintRepoEntry(path, string(repoLogs)) 286 | } 287 | 288 | // PrintGitStatus Pretty print status for a given git repository 289 | func PrintGitStatus(path string, skipEmpty bool, plainStatus bool) { 290 | repoStatus := GetGitStatus(path, plainStatus) 291 | 292 | if plainStatus { 293 | if skipEmpty && strings.Count(repoStatus, "\n") <= 1 { 294 | return 295 | } 296 | } else { 297 | if skipEmpty && strings.Contains(repoStatus, "working tree clean") { 298 | return 299 | } 300 | } 301 | 302 | PrintRepoEntry(path, string(repoStatus)) 303 | } 304 | 305 | // PrintGitPull Pretty print git pull output for a given git repository 306 | func PrintGitPull(path string) { 307 | pullMsg := PullGitRepo(path) 308 | 309 | PrintRepoEntry(path, string(pullMsg)) 310 | } 311 | 312 | // PrintGitSync Pretty print git sync output for a given git repository 313 | func PrintGitSync(path string) { 314 | syncMsg := SyncGitRepo(path) 315 | 316 | PrintRepoEntry(path, string(syncMsg)) 317 | } 318 | 319 | // PrintCheckGit Pretty print git url validation 320 | func PrintCheckGit(path string, url string, version string, enablePrompt bool) bool { 321 | var checkMsg string 322 | var isURLValid bool 323 | if isURLValid, err := IsGitURLValid(url, version, enablePrompt); !isURLValid { 324 | checkMsg = fmt.Sprintf("%sFailed to contact git repository '%s' with version '%s'. Error: %v%s\n", RedColor, url, version, err, ResetColor) 325 | } else { 326 | checkMsg = fmt.Sprintf("Successfully contact git repository '%s' with version '%s'\n", url, version) 327 | } 328 | PrintRepoEntry(path, checkMsg) 329 | return isURLValid 330 | } 331 | 332 | // PrintGitClone Pretty print git clone 333 | func PrintGitClone(url string, version string, path string, overwriteExisting bool, shallowClone bool, enablePrompt bool, recurseSubmodules bool) bool { 334 | var cloneMsg string 335 | var cloneSuccessful bool 336 | statusClone := GitClone(url, version, path, overwriteExisting, shallowClone, enablePrompt, recurseSubmodules) 337 | switch statusClone { 338 | case SuccessfullClone: 339 | cloneMsg = fmt.Sprintf("Successfully cloned git repository '%s' with version '%s'\n", url, version) 340 | cloneSuccessful = true 341 | case SkippedClone: 342 | cloneMsg = fmt.Sprintf("%sSkipped cloning existing git repository '%s'%s\n", OrangeColor, url, ResetColor) 343 | cloneSuccessful = true 344 | case FailedClone: 345 | cloneMsg = fmt.Sprintf("%sFailed to clone git repository '%s' with version '%s'%s\n", RedColor, url, version, ResetColor) 346 | cloneSuccessful = false 347 | case SwitchedBranch: 348 | cloneMsg = fmt.Sprintf("Successfully switched to version '%s' in existing git repository '%s'\n", version, url) 349 | cloneSuccessful = true 350 | default: 351 | panic("Unexpected behavior!") 352 | } 353 | PrintRepoEntry(path, cloneMsg) 354 | return cloneSuccessful 355 | } 356 | 357 | // PrintGitSwitch Pretty print git switch 358 | func PrintGitSwitch(path string, branch string, createBranch bool, detachHead bool) bool { 359 | switchMsg, err := GitSwitch(path, branch, createBranch, detachHead) 360 | if err == nil { 361 | PrintRepoEntry(path, string(switchMsg)) 362 | return true 363 | } 364 | errorMsg := fmt.Sprintf("%sError: '%s'%s\n", RedColor, err, ResetColor) 365 | PrintRepoEntry(path, string(errorMsg)) 366 | return false 367 | } 368 | -------------------------------------------------------------------------------- /utils/print_helpers.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "fmt" 4 | 5 | const ( 6 | BlueColor = "\033[38;2;137;180;250m" 7 | GreenColor = "\033[38;5;157m" 8 | OrangeColor = "\033[38;2;250;179;135m" 9 | PurpleColor = "\033[38;5;183m" 10 | RedColor = "\033[38;2;243;139;168m" 11 | ResetColor = "\033[0m" 12 | ) 13 | 14 | func PrintRepoEntry(path string, msg string) { 15 | fmt.Printf("%s=== %s ===%s\n%s", BlueColor, path, ResetColor, msg) 16 | } 17 | 18 | func PrintSection(msg string) { 19 | fmt.Printf("%s%s%s\n", GreenColor, msg, ResetColor) 20 | } 21 | func PrintSeparator() { 22 | fmt.Printf("%s--------------------%s\n", PurpleColor, ResetColor) 23 | } 24 | 25 | func PrintInfoMsg(msg string) { 26 | fmt.Printf("%s%s%s", BlueColor, msg, ResetColor) 27 | } 28 | 29 | func PrintWarnMsg(msg string) { 30 | fmt.Printf("%s%s%s", OrangeColor, msg, ResetColor) 31 | } 32 | 33 | func PrintErrorMsg(msg string) { 34 | fmt.Printf("%s%s%s\n", RedColor, msg, ResetColor) 35 | } 36 | -------------------------------------------------------------------------------- /utils/repos_helpers.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/jesseduffield/yaml" 11 | ) 12 | 13 | type RepositoryJob struct { 14 | RepoPath string 15 | Repo Repository 16 | } 17 | 18 | type Repository struct { 19 | Type string `yaml:"type"` 20 | URL string `yaml:"url"` 21 | Version string `yaml:"version,omitempty"` 22 | Exclude []string `yaml:"exclude,omitempty"` 23 | } 24 | type RepositoryRosinstall struct { 25 | LocalName string `yaml:"local-name"` 26 | URL string `yaml:"uri"` 27 | Version string `yaml:"version,omitempty"` 28 | Exclude []string `yaml:"exclude,omitempty"` 29 | } 30 | 31 | type Config struct { 32 | Repositories map[string]Repository `yaml:"repositories"` 33 | } 34 | 35 | func (c *Config) UnmarshalYAML(unmarshal func(any) error) error { 36 | // Try to unmarshal as .repos format 37 | type configA Config 38 | var a configA 39 | if err := unmarshal(&a); err == nil && a.Repositories != nil { 40 | *c = Config(a) 41 | return nil 42 | } 43 | 44 | // Try to unmarshal as .rosinstall format 45 | var b []map[string]RepositoryRosinstall 46 | if err := unmarshal(&b); err == nil { 47 | repositories := make(map[string]Repository) 48 | for _, item := range b { 49 | for key, repo := range item { 50 | 51 | repositories[repo.LocalName] = Repository{ 52 | Type: key, 53 | URL: repo.URL, 54 | Version: repo.Version, 55 | Exclude: repo.Exclude, 56 | } 57 | } 58 | } 59 | c.Repositories = repositories 60 | return nil 61 | } 62 | return fmt.Errorf("failed to unmarshal as either .repos or .rosinstall format") 63 | } 64 | 65 | // IsReposFileValid Check if given filePath exists and if has .repos suffix 66 | func IsReposFileValid(filePath string) error { 67 | 68 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 69 | return errors.New("error: File does not exist") 70 | } 71 | 72 | if !strings.HasSuffix(filePath, ".repos") && !strings.HasSuffix(filePath, ".rosinstall") { 73 | return errors.New("error: File given does not have a valid .repos or .rosinstall extension") 74 | } 75 | return nil 76 | } 77 | 78 | // ParseReposFile Load data from a given .repos file 79 | func ParseReposFile(filePath string) (*Config, error) { 80 | errValid := IsReposFileValid(filePath) 81 | if errValid != nil { 82 | return nil, errValid 83 | } 84 | yamlFile, err := os.ReadFile(filePath) 85 | 86 | if err != nil { 87 | errorMsg := "failed to read repos file" 88 | // fmt.Printf("%s: %s\n", errorMsg, err) 89 | return nil, errors.New(errorMsg) 90 | } 91 | 92 | // parse YAML content 93 | var config Config 94 | err = yaml.Unmarshal(yamlFile, &config) 95 | if err != nil { 96 | errorMsg := "failed to parse repos file" 97 | // fmt.Printf("%s: %s\n", errorMsg, err) 98 | return nil, errors.New(errorMsg) 99 | } 100 | 101 | if len(config.Repositories) == 0 { 102 | errorMsg := "empty repos file given" 103 | // fmt.Printf("%s: %s\n", errorMsg, err) 104 | return nil, errors.New(errorMsg) 105 | } 106 | return &config, nil 107 | 108 | } 109 | 110 | // FindReposFiles Search .repos files in a given path 111 | func FindReposFiles(rootPath string, clonedPaths []string) ([]string, error) { 112 | var foundReposFiles []string 113 | var err error 114 | if len(clonedPaths) == 0 { 115 | err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error { 116 | if err != nil { 117 | return err 118 | } 119 | if !info.IsDir() && (filepath.Ext(path) == ".repos") { 120 | foundReposFiles = append(foundReposFiles, path) 121 | } 122 | return nil 123 | }) 124 | } else { 125 | for _, clonedPath := range clonedPaths { 126 | err = filepath.Walk(clonedPath, func(path string, info os.FileInfo, err error) error { 127 | if err != nil { 128 | return err 129 | } 130 | if !info.IsDir() && (filepath.Ext(path) == ".repos") { 131 | foundReposFiles = append(foundReposFiles, path) 132 | } 133 | return nil 134 | }) 135 | } 136 | } 137 | 138 | return foundReposFiles, err 139 | } 140 | 141 | // FindDirectory Search for a targetDir given a rootPath 142 | func FindDirectory(rootPath string, targetDir string) (string, error) { 143 | if len(rootPath) == 0 { 144 | return "", errors.New("empty rootPath given") 145 | } 146 | if len(targetDir) == 0 { 147 | return "", errors.New("empty targetDir given") 148 | } 149 | if rootInfo, err := os.Stat(rootPath); err != nil || !rootInfo.IsDir() { 150 | return "", err 151 | } 152 | if _, err := os.Stat(targetDir); err == nil { 153 | return "", errors.New("targetDir is a Path!. Expected just a name") 154 | } 155 | 156 | var dirPath string 157 | err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error { 158 | if err != nil { 159 | return err 160 | } 161 | if info.IsDir() && info.Name() == targetDir { 162 | dirPath = path 163 | return filepath.SkipDir 164 | } 165 | return nil 166 | }) 167 | if err != nil { 168 | return "", err 169 | } 170 | return dirPath, nil 171 | } 172 | 173 | // ParseRepositoryInfo Create a Repository object containing the given repository info 174 | func ParseRepositoryInfo(repoPath string, useCommit bool) Repository { 175 | var repository Repository 176 | if !IsGitRepository(repoPath) { 177 | return repository 178 | } 179 | repository.Type = "git" 180 | repository.URL = GetGitRemoteURL(repoPath) 181 | if useCommit { 182 | repository.Version = GetGitCommitSha(repoPath) 183 | } else { 184 | repository.Version = GetGitBranch(repoPath) 185 | } 186 | return repository 187 | } 188 | 189 | func GetRepoPath(repoName string) string { 190 | repoNameInfo, err := os.Stat(repoName) 191 | 192 | if err == nil { 193 | if !repoNameInfo.IsDir() { 194 | PrintErrorMsg(fmt.Sprintf("%s is not a directory\n", repoName)) 195 | os.Exit(1) 196 | } 197 | return repoName 198 | } 199 | 200 | if !os.IsNotExist(err) { 201 | PrintErrorMsg(fmt.Sprintf("Error checking repository: %s\n", repoName)) 202 | os.Exit(1) 203 | } 204 | 205 | foundRepoPath, findErr := FindDirectory(".", repoName) 206 | if findErr != nil || foundRepoPath == "" { 207 | PrintErrorMsg(fmt.Sprintf("Failed to find directory named %s. Error: %v\n", repoName, findErr)) 208 | os.Exit(1) 209 | } 210 | return foundRepoPath 211 | } 212 | --------------------------------------------------------------------------------