diff --git a/pkg/commands/command_cleanup.go b/pkg/commands/command_cleanup.go index d77a70b..6051488 100644 --- a/pkg/commands/command_cleanup.go +++ b/pkg/commands/command_cleanup.go @@ -8,8 +8,9 @@ import ( ) var cleanupCmd = &cobra.Command{ - Use: "cleanup", - Short: "Clean up untracked local repositories", + Use: "cleanup", + Aliases: []string{"clean", "cl"}, + Short: "Clean up untracked local repositories", Long: "Clean up untracked local repositories.\n\n" + "Multiple selection is possible (default: all).", Example: "gh gr cleanup", diff --git a/pkg/commands/command_init.go b/pkg/commands/command_init.go index d8d4502..97db2d8 100644 --- a/pkg/commands/command_init.go +++ b/pkg/commands/command_init.go @@ -6,8 +6,9 @@ import ( var initCmd = func() *cobra.Command { initCmd := &cobra.Command{ - Use: "init", - Short: "Initialize repository mirror", + Use: "init", + Aliases: []string{"setup"}, + Short: "Initialize repository mirror", Long: "Initialize repository mirror.\n\n" + "Automatically generates a list of repositories a given user has permissions to.\n" + "Supports filtering by repository blob size and with regular expressions.\n" + @@ -40,8 +41,8 @@ var initCmd = func() *cobra.Command { flags.StringVarP(&configFlags.BaseDirectory, "dir", "d", ".", "Directory in which repositories will be stored (either absolute or relative)") flags.BoolVarP(&configFlags.SubDirectories, "subdirs", "s", false, "Enable creation of separate subdirectories for each org/user") flags.Uint64VarP(&configFlags.SizeLimit, "sizelimit", "l", 0, "Exclude repositories with size exceeded the limit (\"0\": no limit, e.g. limit of 52,428,800 corresponds with 50 MB)") - flags.StringArrayVarP(&configFlags.Excluded, "exclude", "e", []string{}, "Regular expressions of repositories to exclude") - flags.StringArrayVarP(&configFlags.Included, "include", "i", []string{}, "Regular expressions of repositories to include explicitly") + flags.StringArrayVarP(&configFlags.Excluded, "exclude", "e", []string{}, "Regular expressions for repositories to exclude") + flags.StringArrayVarP(&configFlags.Included, "include", "i", []string{}, "Regular expressions for repositories to include explicitly") return initCmd }() diff --git a/pkg/commands/command_pull.go b/pkg/commands/command_pull.go index c591273..a95b0c9 100644 --- a/pkg/commands/command_pull.go +++ b/pkg/commands/command_pull.go @@ -92,8 +92,8 @@ func pullOperation(_ pool.WorkUnit, args operationContext) { logger := loggerEntry.WithField("command", "pull").WithField("repository", repo.Directory) - conf.Authenticate(&repo.URL) - conf.Authenticate(&repo.ParentURL) + conf.AuthenticateURL(&repo.URL) + conf.AuthenticateURL(&repo.ParentURL) logger.Debugf("Authenticated: URL: %t, ParentURL: %t", repo.URL != "", repo.ParentURL != "") defer util.Chdir(conf.AbsoluteDirectoryPath).Popd() @@ -255,17 +255,15 @@ func updateRepoConfig(conf *configfile.Configuration, host string, repository *g // update remote "origin" urls to use current authentication context if cfg, ok := repoConf.Remotes["origin"]; ok { for i := range cfg.URLs { - conf.Generalize(&cfg.URLs[i]) - conf.Authenticate(&cfg.URLs[i]) + conf.AuthenticateURL(&cfg.URLs[i]) } repoConf.Remotes["origin"] = cfg } - // update submodules urls to use current authentication context + // update submodules' urls to use current authentication context for name, cfg := range repoConf.Submodules { - conf.Generalize(&cfg.URL) - conf.Authenticate(&cfg.URL) + conf.AuthenticateURL(&cfg.URL) repoConf.Submodules[name] = cfg } diff --git a/pkg/commands/command_push.go b/pkg/commands/command_push.go index 5a1c049..80cbd6d 100644 --- a/pkg/commands/command_push.go +++ b/pkg/commands/command_push.go @@ -29,8 +29,8 @@ func pushOperation(_ pool.WorkUnit, args operationContext) { logger := loggerEntry.WithField("command", "push").WithField("repository", repo.Directory) - conf.Authenticate(&repo.URL) - conf.Authenticate(&repo.ParentURL) + conf.AuthenticateURL(&repo.URL) + conf.AuthenticateURL(&repo.ParentURL) logger.Debugf("Authenticated: URL: %t, ParentURL: %t", repo.URL != "", repo.ParentURL != "") defer util.Chdir(conf.AbsoluteDirectoryPath).Popd() diff --git a/pkg/commands/command_remove.go b/pkg/commands/command_remove.go index 5a13ac9..15194c3 100644 --- a/pkg/commands/command_remove.go +++ b/pkg/commands/command_remove.go @@ -11,8 +11,9 @@ var removeCmd = func() *cobra.Command { var purge bool removeCmd := &cobra.Command{ - Use: "remove", - Short: "Remove current configuration", + Use: "remove", + Aliases: []string{"reset", "rm", "delete", "del"}, + Short: "Remove current configuration", Long: "Remove current configuration.\n\n" + "To remove local repositories as well, provide the \"--purge\" option.", Example: "gh gr remove --purge", diff --git a/pkg/commands/command_status.go b/pkg/commands/command_status.go index 95d8cc8..cc6bd37 100644 --- a/pkg/commands/command_status.go +++ b/pkg/commands/command_status.go @@ -38,8 +38,8 @@ func statusOperation(_ pool.WorkUnit, args operationContext) { logger := loggerEntry.WithField("command", "status").WithField("repository", repo.Directory) - conf.Authenticate(&repo.URL) - conf.Authenticate(&repo.ParentURL) + conf.AuthenticateURL(&repo.URL) + conf.AuthenticateURL(&repo.ParentURL) logger.Debugf("Authenticated: URL: %t, ParentURL: %t", repo.URL != "", repo.ParentURL != "") defer util.Chdir(conf.AbsoluteDirectoryPath).Popd() diff --git a/pkg/commands/command_view.go b/pkg/commands/command_view.go index 047a2b0..819c138 100644 --- a/pkg/commands/command_view.go +++ b/pkg/commands/command_view.go @@ -12,9 +12,10 @@ import ( var viewCmd = func() *cobra.Command { var formatOption string + var filters []string viewCmd := &cobra.Command{ - Aliases: []string{"show", "list"}, + Aliases: []string{"show", "list", "ls"}, Use: "view", Short: "Display current configuration", Long: "Display current configuration.\n\n" + @@ -30,13 +31,14 @@ var viewCmd = func() *cobra.Command { conf := configfile.Load() logger.Debug("Streaming") - conf.Display(formatOption, false) + conf.Display(formatOption, false, filters...) }, } flags := viewCmd.Flags() supportedFormats := strings.Join(configfile.GetListOfSupportedFormats(true), ", ") flags.StringVarP(&formatOption, "format", "f", "yaml", fmt.Sprintf("Change output format, supported formats: [%s]", supportedFormats)) + flags.StringArrayVarP(&filters, "match", "m", []string{}, "Glob pattern(s) to filter repositories") return viewCmd }() diff --git a/pkg/configfile/configuration.go b/pkg/configfile/configuration.go index 7439179..3ddcb63 100644 --- a/pkg/configfile/configuration.go +++ b/pkg/configfile/configuration.go @@ -84,7 +84,7 @@ type Configuration struct { Repositories Repositories `json:"repositories" yaml:"repositories"` } -// Append multiple repositories and sort them alphabetically by Directory. +// AppendRepositories appends multiple repositories to the configuration and sorts them alphabetically by Directory. func (conf *Configuration) AppendRepositories(user *resources.User, repos ...resources.Repository) { for _, repo := range repos { dir := repo.FullName @@ -125,9 +125,9 @@ func (conf *Configuration) AppendRepositories(user *resources.User, repos ...res loggerEntry.Debugf("Configured %d repositories", conf.Total) } -// Encode username and token into URL. +// AuthenticateURL encodes username and token into URL. // In the case, no matching token can be found for given URL, emit message and exit. -func (conf Configuration) Authenticate(targetURL *string) { +func (conf Configuration) AuthenticateURL(targetURL *string) { loggerEntry.Debugf("Authenticating URL: %v", targetURL) if targetURL == nil || *targetURL == "" || !urlRegex.MatchString(*targetURL) { loggerEntry.Debugf("Got empty or invalid URL: %#v", targetURL) @@ -257,7 +257,7 @@ func (conf *Configuration) Copy() *Configuration { // Flush config into Stdout. // Supports multiple formats and partial emission (if !export). -func (conf Configuration) Display(format string, export bool) { +func (conf Configuration) Display(format string, export bool, filters ...string) { reader, writer := io.Pipe() c := util.Console() @@ -273,6 +273,16 @@ func (conf Configuration) Display(format string, export bool) { defer func(w io.Writer) { util.Logger.SetOutput(w) }(out) } + if len(filters) > 0 { + var repositories []Repository + for _, repository := range conf.Repositories { + if util.PatternList(filters).GlobMatch(util.StripPathPrefix(repository.Directory, 1)) { + repositories = append(repositories, repository) + } + } + conf.Repositories = repositories + } + go func() { defer writer.Close() supererrors.Except(enc.Encoder(writer, !export && c.ColorsEnabled()).Encode(conf)) @@ -319,9 +329,9 @@ func (conf *Configuration) FilterRepositories(repositories *[]resources.Reposito case // not explicitly included - len(conf.Included) > 0 && !util.RegexList(conf.Included).Match(repo.FullName, conf.Timeout), + len(conf.Included) > 0 && !util.PatternList(conf.Included).RegexMatch(repo.FullName, conf.Timeout), // explicitly excluded - len(conf.Excluded) > 0 && util.RegexList(conf.Excluded).Match(repo.FullName, conf.Timeout), + len(conf.Excluded) > 0 && util.PatternList(conf.Excluded).RegexMatch(repo.FullName, conf.Timeout), // repository size exceeds size limit conf.SizeLimit > 0 && uint64(repo.Size) > conf.SizeLimit, // repository is archived or disabled @@ -339,8 +349,8 @@ func (conf *Configuration) FilterRepositories(repositories *[]resources.Reposito } } -// Remove username and token from URL. -func (conf Configuration) Generalize(targetURL *string) { +// GeneralizeURL removes username and token from URL. +func (conf Configuration) GeneralizeURL(targetURL *string) { if targetURL == nil || *targetURL == "" || !urlRegex.MatchString(*targetURL) { loggerEntry.Debugf("Got empty or invalid URL: %#v", targetURL) return diff --git a/pkg/configfile/configuration_test.go b/pkg/configfile/configuration_test.go index c41247a..c91c393 100644 --- a/pkg/configfile/configuration_test.go +++ b/pkg/configfile/configuration_test.go @@ -39,7 +39,7 @@ gr.conf: | branch: main ` -func TestConfigurationAuthenticate(t *testing.T) { +func TestConfigurationAuthenticateURL(t *testing.T) { conf := &Configuration{ Profiles: Profiles{{Username: "user", Host: "example.com"}}, } @@ -63,7 +63,7 @@ func TestConfigurationAuthenticate(t *testing.T) { } { t.Run(tt.name, func(t *testing.T) { got := tt.args - conf.Authenticate(&got) + conf.AuthenticateURL(&got) if got != tt.want { t.Errorf(`conf.Authenticate(&(%q)) failed: got: %q, want %q`, tt.args, got, tt.want) } @@ -155,11 +155,11 @@ func TestConfigurationGeneralize(t *testing.T) { {"test#1", "", ""}, {"test#2", "https://user:pass@example.com", "https://example.com"}, {"test#3", "https://example.com", "https://example.com"}, - {"test#4", "https://example.com/q=1", "https://example.com/q=1"}, + {"test#4", "https://example.com?q=1", "https://example.com?q=1"}, } { t.Run(tt.name, func(t *testing.T) { got := tt.args - conf.Generalize(&got) + conf.GeneralizeURL(&got) if got != tt.want { t.Errorf(`conf.Generalize(&(%q)) failed: got: %q, want %q`, tt.args, got, tt.want) } diff --git a/pkg/restclient/rest_client.go b/pkg/restclient/rest_client.go index 8e397af..1db4a68 100644 --- a/pkg/restclient/rest_client.go +++ b/pkg/restclient/rest_client.go @@ -32,7 +32,7 @@ func (c RESTClient) DoWithContext(ctx context.Context, method string, path strin } // Get all repositories for given user and the organizations he belongs to. -func (c RESTClient) GetAllUserRepos(ctx context.Context, include, exclude util.RegexList) ([]resources.Repository, error) { +func (c RESTClient) GetAllUserRepos(ctx context.Context, include, exclude []string) ([]resources.Repository, error) { repos, err := c.GetUserRepos(ctx) if err != nil { return nil, err @@ -49,10 +49,10 @@ func (c RESTClient) GetAllUserRepos(ctx context.Context, include, exclude util.R } for _, org := range orgs { - switch { + switch includes, excludes := util.PatternList(include), util.PatternList(exclude); { case - len(include) > 0 && !(include.Match(org.Login+"/someRepository", timeout) || include.Match(org.Login+"/", timeout)), - len(exclude) > 0 && (exclude.Match(org.Login+"/someRepository", timeout) || exclude.Match(org.Login+"/", timeout)): + len(include) > 0 && !(includes.RegexMatch(org.Login+"/someRepository", timeout) || includes.RegexMatch(org.Login+"/", timeout)), + len(exclude) > 0 && (excludes.RegexMatch(org.Login+"/someRepository", timeout) || excludes.RegexMatch(org.Login+"/", timeout)): continue } diff --git a/pkg/util/colored_yaml_encoder.go b/pkg/util/colored_yaml_encoder.go index 1b86f72..c588bd4 100644 --- a/pkg/util/colored_yaml_encoder.go +++ b/pkg/util/colored_yaml_encoder.go @@ -6,10 +6,10 @@ import ( "io" "regexp" - "github.com/fatih/color" + color "github.com/fatih/color" yaml "github.com/goccy/go-yaml" - "github.com/goccy/go-yaml/lexer" - "github.com/goccy/go-yaml/printer" + lexer "github.com/goccy/go-yaml/lexer" + printer "github.com/goccy/go-yaml/printer" ) // Regular expression for matching ANSI code sequences for color codes. diff --git a/pkg/util/environ.go b/pkg/util/environ.go index 421a01b..54116c1 100644 --- a/pkg/util/environ.go +++ b/pkg/util/environ.go @@ -16,7 +16,7 @@ func Getenv(key envVariable) string { return os.Getenv(EnvPrefix + string(key)) // Retrieve environment variable of boolean type. func GetenvBool(key envVariable) bool { - switch Getenv(Verbose) { + switch Getenv(key) { case "true", "TRUE", "True", "1", "Y", "y", "YES", "yes": return true diff --git a/pkg/util/path.go b/pkg/util/path.go index d7e3bd1..d2ec90b 100644 --- a/pkg/util/path.go +++ b/pkg/util/path.go @@ -138,3 +138,26 @@ func PathSanitize(paths ...*string) { *path = strings.TrimSuffix(*path, "/") } } + +// StripPathPrefix strips prefix from path given a number of parent directories to keep. +func StripPathPrefix(path string, keepParents uint) string { + if path == "" { + return filepath.FromSlash("") + } + + if p, err := filepath.Abs(path); err == nil { + path = p + } + + if length := len(filepath.SplitList(path)); length > int(keepParents) { + keepParents = uint(length) + } + + parts := []string{filepath.Base(path)} + for i := uint(0); i < keepParents; i++ { + path = filepath.Dir(path) + parts = append([]string{filepath.Base(path)}, parts...) + } + + return filepath.Join(parts...) +} diff --git a/pkg/util/path_test.go b/pkg/util/path_test.go index 4372648..30bb156 100644 --- a/pkg/util/path_test.go +++ b/pkg/util/path_test.go @@ -54,3 +54,29 @@ func TestPathSanitize(t *testing.T) { }) } } + +func TestStripPathPrefix(t *testing.T) { + type args struct { + path string + keepParents uint + } + + for _, tt := range []struct { + name string + args args + want string + }{ + {"test#1", args{"", 0}, ""}, + {"test#2", args{"/dir1/dir2/item", 1}, "dir2/item"}, + {"test#3", args{"/dir1/dir2/item", 2}, "dir1/dir2/item"}, + {"test#4", args{"/dir1/../dir2/item", 1}, "dir2/item"}, + {"test#5", args{"/dir1/../dir2/item", 10}, "/dir2/item"}, + } { + t.Run(tt.name, func(t *testing.T) { + got := StripPathPrefix(tt.args.path, tt.args.keepParents) + if got != tt.want { + t.Errorf(`StripPathPrefix(%q, %d) failed: got: %q, want: %q`, tt.args.path, tt.args.keepParents, got, tt.want) + } + }) + } +} diff --git a/pkg/util/pattern_list.go b/pkg/util/pattern_list.go new file mode 100644 index 0000000..d23abdc --- /dev/null +++ b/pkg/util/pattern_list.go @@ -0,0 +1,47 @@ +package util + +import ( + "path/filepath" + "time" + + regexp2 "github.com/dlclark/regexp2" +) + +// PatternList is a list of regular expressions. +type PatternList []string + +// GlobMatch checks if target matches any of the globs in the list. +func (l PatternList) GlobMatch(target string) bool { + for _, pattern := range l { + ok, err := filepath.Match(pattern, target) + if err != nil { + continue + } + + if ok { + return true + } + } + + return false +} + +// RegexMatch checks if target matches any of the regular expressions in the list. +func (l PatternList) RegexMatch(target string, timeout time.Duration) bool { + for _, pattern := range l { + re, err := regexp2.Compile(pattern, regexp2.RE2) + if err != nil { + continue + } + + if timeout > 0 { + re.MatchTimeout = timeout + } + + if match, err := re.MatchString(target); err == nil && match { + return true + } + } + + return false +} diff --git a/pkg/util/pattern_list_test.go b/pkg/util/pattern_list_test.go new file mode 100644 index 0000000..ba83703 --- /dev/null +++ b/pkg/util/pattern_list_test.go @@ -0,0 +1,53 @@ +package util + +import ( + "testing" + "time" +) + +func TestPatternListGlobMatch(t *testing.T) { + type args struct { + list []string + target string + } + for _, tt := range []struct { + name string + args args + want bool + }{ + {"test#1", args{[]string{""}, ""}, true}, + {"test#2", args{[]string{`owner/*`}, "owner/subject"}, true}, + {"test#3", args{[]string{`owner/[a-zA-Z]*`}, "owner/!@$"}, false}, + } { + t.Run(tt.name, func(t *testing.T) { + got := PatternList(tt.args.list).GlobMatch(tt.args.target) + if got != tt.want { + t.Errorf(`GlobList(%v).Match(%q) failed: got: %t, want: %t`, tt.args.list, tt.args.target, got, tt.want) + } + }) + } + +} + +func TestPatternListRegexMatch(t *testing.T) { + type args struct { + list []string + target string + } + for _, tt := range []struct { + name string + args args + want bool + }{ + {"test#1", args{[]string{""}, ""}, true}, + {"test#2", args{[]string{`^owner/\w*$`}, "owner/subject"}, true}, + {"test#3", args{[]string{`^owner/\w+$`}, "owner/!@$"}, false}, + } { + t.Run(tt.name, func(t *testing.T) { + got := PatternList(tt.args.list).RegexMatch(tt.args.target, time.Second) + if got != tt.want { + t.Errorf(`RegexList(%v).Match(%q) failed: got: %t, want: %t`, tt.args.list, tt.args.target, got, tt.want) + } + }) + } +} diff --git a/pkg/util/regex_list.go b/pkg/util/regex_list.go deleted file mode 100644 index 72a3a2d..0000000 --- a/pkg/util/regex_list.go +++ /dev/null @@ -1,30 +0,0 @@ -package util - -import ( - "time" - - "github.com/dlclark/regexp2" -) - -// Custom type to implement regex matcher for any of. -type RegexList []string - -// Match at least once for given target. -func (l RegexList) Match(target string, timeout time.Duration) bool { - for _, regex := range l { - re, err := regexp2.Compile(regex, regexp2.RE2) - if err != nil { - continue - } - - if timeout > 0 { - re.MatchTimeout = timeout - } - - if match, err := re.MatchString(target); err == nil && match { - return true - } - } - - return false -} diff --git a/pkg/util/regex_list_test.go b/pkg/util/regex_list_test.go deleted file mode 100644 index 99b56b7..0000000 --- a/pkg/util/regex_list_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package util - -import ( - "testing" - "time" -) - -func TestRegexListMatch(t *testing.T) { - type args struct { - list []string - target string - } - for _, tt := range []struct { - name string - args args - want bool - }{ - {"test#1", args{[]string{""}, ""}, true}, - {"test#2", args{[]string{`^owner/\w*$`}, "owner/subject"}, true}, - {"test#3", args{[]string{`^owner/\w+$`}, "owner/!@$"}, false}, - } { - t.Run(tt.name, func(t *testing.T) { - got := RegexList(tt.args.list).Match(tt.args.target, time.Second) - if got != tt.want { - t.Errorf(`RegexList(%v).Match(%q) failed: got: %t, want: %t`, tt.args.list, tt.args.target, got, tt.want) - } - }) - } - -}