diff --git a/cmd/clone.go b/cmd/clone.go index 7ce976a..283dc40 100644 --- a/cmd/clone.go +++ b/cmd/clone.go @@ -12,13 +12,15 @@ import ( ) var ( - topics []string + topics []string + excludeTopics []string ) func init() { RootCmd.AddCommand(cloneCmd) cloneCmd.Flags().StringSliceVarP(&topics, "topics", "t", []string{}, "clone only repos with matching topics") + cloneCmd.Flags().StringSliceVarP(&excludeTopics, "exclude-topics", "e", []string{}, "skip repos that have any of the specified topics") } var cloneCmd = &cobra.Command{ @@ -27,8 +29,9 @@ var cloneCmd = &cobra.Command{ Long: `Clone all active (non-archived) repositories from a GitHub org or user into the target directory. The optional directory argument specifies where to clone the repos (defaults to the current directory). -When --topics is provided multiple times, only repos that have ALL specified topics are cloned (AND logic). -To clone repos matching any one topic, run separate clone invocations per topic.`, +When --topics is provided, only repos that have ALL specified topics are cloned (AND logic). +When --exclude-topics is provided, repos that have ANY of the specified topics are skipped. +Both flags may be combined: clone repos with topic A but not topic B.`, Args: cobra.MaximumNArgs(2), ValidArgsFunction: createCmdValidArgsFunc, PersistentPreRun: setupClient, @@ -61,6 +64,7 @@ func cloneFunc(cmd *cobra.Command, args []string) error { } repos = filterByTopics(repos, topics) + repos = excludeByTopics(repos, excludeTopics) ctx = ctxhelper.WithRepos(ctx, repos) @@ -112,3 +116,32 @@ func filterByTopics(repos []*github.Repository, topics []string) []*github.Repos return filtered } + +func excludeByTopics(repos []*github.Repository, excludeTopics []string) []*github.Repository { + if len(excludeTopics) == 0 { + return repos + } + + excludeSet := make(map[string]struct{}, len(excludeTopics)) + for _, t := range excludeTopics { + excludeSet[t] = struct{}{} + } + + var filtered []*github.Repository + + for _, r := range repos { + excluded := false + for _, t := range r.Topics { + if _, ok := excludeSet[t]; ok { + excluded = true + break + } + } + + if !excluded { + filtered = append(filtered, r) + } + } + + return filtered +} diff --git a/cmd/clone_test.go b/cmd/clone_test.go index d9d70f9..5a11ff8 100644 --- a/cmd/clone_test.go +++ b/cmd/clone_test.go @@ -57,12 +57,64 @@ func TestClone(t *testing.T) { }) } +func TestExcludeByTopics(t *testing.T) { + repoWithTopics := func(topics ...string) *github.Repository { + return &github.Repository{Topics: topics} + } + + t.Run("returns all repos when no exclude topics", func(t *testing.T) { + t.Parallel() + + repos := []*github.Repository{ + repoWithTopics("go", "cli"), + repoWithTopics("rust"), + } + + result := excludeByTopics(repos, []string{}) + assert.Equal(t, repos, result) + }) + + t.Run("excludes repos with matching topic", func(t *testing.T) { + t.Parallel() + + keep := repoWithTopics("go", "cli") + skip := repoWithTopics("rust") + + result := excludeByTopics([]*github.Repository{keep, skip}, []string{"rust"}) + assert.Equal(t, []*github.Repository{keep}, result) + }) + + t.Run("excludes repos matching any excluded topic", func(t *testing.T) { + t.Parallel() + + keep := repoWithTopics("go") + skipA := repoWithTopics("rust") + skipB := repoWithTopics("python") + + result := excludeByTopics([]*github.Repository{keep, skipA, skipB}, []string{"rust", "python"}) + assert.Equal(t, []*github.Repository{keep}, result) + }) + + t.Run("returns nil when all repos excluded", func(t *testing.T) { + t.Parallel() + + repos := []*github.Repository{ + repoWithTopics("rust"), + } + + result := excludeByTopics(repos, []string{"rust"}) + assert.Nil(t, result) + }) +} + func TestFilterByTopics(t *testing.T) { repoWithTopics := func(topics ...string) *github.Repository { return &github.Repository{Topics: topics} } t.Run("returns all repos when no topics filter", func(t *testing.T) { + t.Parallel() + repos := []*github.Repository{ repoWithTopics("go", "cli"), repoWithTopics("rust"), @@ -73,6 +125,8 @@ func TestFilterByTopics(t *testing.T) { }) t.Run("filters repos by single topic", func(t *testing.T) { + t.Parallel() + match := repoWithTopics("go", "cli") noMatch := repoWithTopics("rust") @@ -81,6 +135,8 @@ func TestFilterByTopics(t *testing.T) { }) t.Run("filters repos requiring all topics present", func(t *testing.T) { + t.Parallel() + both := repoWithTopics("go", "cli") onlyOne := repoWithTopics("go") @@ -89,6 +145,8 @@ func TestFilterByTopics(t *testing.T) { }) t.Run("returns nil when no repos match", func(t *testing.T) { + t.Parallel() + repos := []*github.Repository{ repoWithTopics("rust"), }