Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,31 @@ jobs:
uses: googleapis/release-please-action@v4
with:
release-type: go

goreleaser:
name: GoReleaser
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.release-please.outputs.tag_name }}

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26"

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1 change: 1 addition & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,5 @@ release:
name: github-pokemon
draft: false
prerelease: auto
mode: append
name_template: "v{{ .Version }}"
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ There are no tests currently in this project.

## Architecture

Single-command Cobra CLI app. All logic lives in two files:
Single-command Cobra CLI app with a `prune-archived` subcommand:

- `main.go` — entrypoint, calls `cmd.Execute()`
- `cmd/root.go` — CLI flags, GitHub API pagination, worker pool for parallel clone/fetch
- `cmd/config.go` — YAML config file loading (`~/.config/github-pokemon/config.yaml`)
- `cmd/prune_archived.go` — subcommand to remove locally-cloned archived repos
- `cmd/output.go` — progress bar and grouped result display
- `cmd/update.go` — background self-update check

The worker pool pattern: `runRootCommand()` creates a buffered channel of repos, spawns `--parallel` (default 5) goroutines via `worker()`, each calling `processRepository()` which either `git clone` or `git fetch --all`.

Expand Down
109 changes: 85 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ A Go tool for efficiently managing multiple GitHub repositories from an organiza
- Quickly clone all non-archived repositories from a GitHub organization
- Update existing repositories by fetching remote tracking branches
- Process repositories in parallel for better performance
- **Config file support** — define multiple org/path pairs and run with no arguments
- **Prune archived repos** — remove local directories for repositories archived on GitHub
- SSH key support for authentication
- Safe operations - never modifies local working directories

Expand Down Expand Up @@ -43,10 +45,10 @@ git clone https://github.com/utahcon/github-pokemon.git
cd github-pokemon

# Build the binary
go build -o github-repo-manager
go build -o github-pokemon

# Optionally move to your path
mv github-repo-manager /usr/local/bin/
mv github-pokemon /usr/local/bin/
```

## Usage
Expand All @@ -56,47 +58,106 @@ mv github-repo-manager /usr/local/bin/
export GITHUB_TOKEN="your-github-personal-access-token"

# Basic usage with required parameters
github-repo-manager --org "organization-name" --path "/path/to/store/repos"
github-pokemon --org "organization-name" --path "/path/to/store/repos"

# Check version
github-repo-manager --version
# Run using a config file (no flags needed)
github-pokemon

# The tool will automatically check if git is installed and if GITHUB_TOKEN is set
# Check version
github-pokemon --version
```

### Command Line Options

```
Flags:
-h, --help Display help information
-o, --org string GitHub organization to fetch repositories from (required)
-j, --parallel int Number of repositories to process in parallel (default 5)
-p, --path string Local path to clone/update repositories to (required)
-s, --skip-update Skip updating existing repositories
-v, --verbose Enable verbose output
-V, --version Show version information and exit
--config string Path to config file (default: ~/.config/github-pokemon/config.yaml)
-h, --help Display help information
--include-archived Include archived repositories
--no-color Disable colored output
-o, --org string GitHub organization to fetch repositories from
-j, --parallel int Number of repositories to process in parallel (default 5)
-p, --path string Local path to clone/update repositories to
-s, --skip-update Skip updating existing repositories
-v, --verbose Enable verbose output
--version Show version information and exit
```

When `--org` and `--path` are both provided, the tool runs in single-org mode. When omitted, it reads from the config file.

### Config File

Instead of passing `--org` and `--path` every time, you can create a config file at `~/.config/github-pokemon/config.yaml` (or set `XDG_CONFIG_HOME`):

```yaml
orgs:
- org: "my-organization"
path: "/home/user/repos/my-org"
- org: "another-org"
path: "/home/user/repos/another-org"

# Optional defaults (can be overridden by CLI flags)
parallel: 10
skip_update: false
verbose: true
include_archived: false
no_color: false
```

Then simply run:

```bash
github-pokemon
```

All orgs will be processed sequentially. You can also point to a custom config file:

```bash
github-pokemon --config /path/to/my-config.yaml
```

### Subcommands

#### `prune-archived`

Remove local directories for repositories that have been archived on GitHub:

```bash
# Dry-run (default) — shows what would be removed
github-pokemon prune-archived --org "my-org" --path "./repos"

# Actually remove archived repo directories
github-pokemon prune-archived --org "my-org" --path "./repos" --confirm

# Or use config file to prune across all orgs
github-pokemon prune-archived
github-pokemon prune-archived --confirm
```

### Examples

```bash
# Clone/fetch with 10 parallel workers
github-repo-manager --org "my-organization" --path "./repos" --parallel 10
github-pokemon --org "my-organization" --path "./repos" --parallel 10

# Skip updating existing repositories
github-repo-manager --org "my-organization" --path "./repos" --skip-update
github-pokemon --org "my-organization" --path "./repos" --skip-update

# Include archived repositories
github-pokemon --org "my-organization" --path "./repos" --include-archived

# Verbose output with status information
github-repo-manager --org "my-organization" --path "./repos" --verbose
github-pokemon --org "my-organization" --path "./repos" --verbose
```

## How It Works

1. The tool queries the GitHub API to list all repositories in the specified organization
2. For each non-archived repository:
- If it doesn't exist locally, it clones the repository
1. The tool reads org/path pairs from CLI flags or the config file
2. For each organization, it queries the GitHub API to list all repositories
3. For each non-archived repository (or all repos if `--include-archived`):
- If it doesn't exist locally, it clones the repository via SSH
- If it exists locally, it only fetches updates to remote tracking branches
3. Local working directories are never modified - the tool only updates remote tracking information
4. Local working directories are never modified - the tool only updates remote tracking information

## Authentication

Expand Down Expand Up @@ -136,10 +197,10 @@ export GITHUB_TOKEN="your-github-personal-access-token"
mkdir -p ~/github-repos

# Clone all repositories from your organization
github-repo-manager --org "your-organization" --path ~/github-repos
github-pokemon --org "your-organization" --path ~/github-repos

# Update repositories daily to stay current (could be in a cron job)
github-repo-manager --org "your-organization" --path ~/github-repos
github-pokemon --org "your-organization" --path ~/github-repos

# After updating, if you want to update local branches in a specific repository:
cd ~/github-repos/specific-repo
Expand All @@ -159,7 +220,7 @@ You can set up a cron job to automatically update your repositories:
crontab -e

# Add a line to run the tool daily at 9 AM
0 9 * * * export GITHUB_TOKEN="your-token"; /path/to/github-repo-manager --org "your-organization" --path ~/github-repos
0 9 * * * export GITHUB_TOKEN="your-token"; /path/to/github-pokemon --org "your-organization" --path ~/github-repos
```

### Troubleshooting
Expand All @@ -171,7 +232,7 @@ If you encounter errors like "permission denied" or "authentication failed":
1. Verify your GitHub token has the correct permissions
2. Check that your SSH key is properly set up with GitHub
3. Ensure your SSH agent is running: `eval "$(ssh-agent -s)"`
4. Try the verbose flag for more detailed output: `github-repo-manager --org "your-org" --path "./repos" --verbose`
4. Try the verbose flag for more detailed output: `github-pokemon --org "your-org" --path "./repos" --verbose`

The tool will automatically detect authentication issues and provide helpful guidance.

Expand Down
161 changes: 161 additions & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package cmd

import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"

"gopkg.in/yaml.v3"
)

// OrgConfig represents a single organization entry in the config file.
type OrgConfig struct {
Org string `yaml:"org"`
Path string `yaml:"path"`
}

// Config represents the top-level configuration file structure.
type Config struct {
Orgs []OrgConfig `yaml:"orgs"`
Parallel int `yaml:"parallel,omitempty"`
SkipUpdate bool `yaml:"skip_update,omitempty"`
Verbose bool `yaml:"verbose,omitempty"`
IncludeArchived bool `yaml:"include_archived,omitempty"`
NoColor bool `yaml:"no_color,omitempty"`
}

// defaultConfigPath returns ~/.config/github-pokemon/config.yaml.
func defaultConfigPath() (string, error) {
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "github-pokemon", "config.yaml"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("determining home directory: %w", err)
}
return filepath.Join(home, ".config", "github-pokemon", "config.yaml"), nil
}

// loadConfig reads and parses a YAML config file.
func loadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("reading config file %s: %w", path, err)
}

var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("parsing config file %s: %w", path, err)
}

if len(cfg.Orgs) == 0 {
return Config{}, fmt.Errorf("config file %s has no orgs defined", path)
}

for i, entry := range cfg.Orgs {
if entry.Org == "" {
return Config{}, fmt.Errorf("config entry %d: \"org\" is required", i+1)
}
if entry.Path == "" {
return Config{}, fmt.Errorf("config entry %d: \"path\" is required", i+1)
}
}

return cfg, nil
}

// loadOrCreateConfig loads an existing config or returns an empty one if the file doesn't exist.
func loadOrCreateConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return Config{}, nil
}
return Config{}, fmt.Errorf("reading config file %s: %w", path, err)
}

var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("parsing config file %s: %w", path, err)
}

return cfg, nil
}

// saveConfig writes the config to disk, creating parent directories as needed.
func saveConfig(path string, cfg Config) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}

data, err := yaml.Marshal(&cfg)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}

if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing config file %s: %w", path, err)
}

return nil
}

// configHasOrg returns true if the config already contains the given org/path pair.
func configHasOrg(cfg Config, org, path string) bool {
for _, entry := range cfg.Orgs {
if entry.Org == org && entry.Path == path {
return true
}
}
return false
}

// configLookupOrg returns the first matching OrgConfig for the given org name, or false if not found.
func configLookupOrg(cfg Config, org string) (OrgConfig, bool) {
for _, entry := range cfg.Orgs {
if entry.Org == org {
return entry, true
}
}
return OrgConfig{}, false
}

// promptToSaveConfig asks the user if they want to save the org/path to the config file.
// Returns true if the entry was saved.
func promptToSaveConfig(org, path, cfgPath string) bool {
fmt.Printf("\nWould you like to save org %q with path %q to your config file? [y/N] ", org, path)

reader := bufio.NewReader(os.Stdin)
answer, err := reader.ReadString('\n')
if err != nil {
return false
}

answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
return false
}

cfg, err := loadOrCreateConfig(cfgPath)
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return false
}

if configHasOrg(cfg, org, path) {
fmt.Println("Already in config, skipping.")
return false
}

cfg.Orgs = append(cfg.Orgs, OrgConfig{Org: org, Path: path})

if err := saveConfig(cfgPath, cfg); err != nil {
fmt.Printf("Error saving config: %v\n", err)
return false
}

fmt.Printf("Saved to %s\n", cfgPath)
return true
}
Loading
Loading