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
11 changes: 11 additions & 0 deletions docs/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ This configuration excludes:
- Any repo ending with `-archive` (e.g., `old-service-archive`)
- Any repo matching `test-*-tmp` (e.g., `test-api-tmp`)

### Including Archived Repositories

By default, archived repositories are ignored. To include them:

```yaml
organization: my-org
include_archived: true
```

With this setting, archived repositories are cloned and synced like any other repository. Without it (the default), archived repositories are skipped and any existing local directories for them are reported as `excluded-but-present`.

## Example Output

The examples below show the stable log lines and summary output. In an interactive terminal (TTY), **ghorgsync** also renders a live progress bar during repository processing; that transient line is omitted here for readability.
Expand Down
10 changes: 9 additions & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ permalink: /usage
| `organization` | string | *(required)* | GitHub organization name to sync |
| `include_public` | boolean | `true` | Include public repositories |
| `include_private` | boolean | `true` | Include private repositories |
| `include_archived` | boolean | `false` | Include archived repositories |
| `exclude_repos` | array | `[]` | Repository names or regex patterns to exclude |

### Exclude Patterns
Expand Down Expand Up @@ -74,7 +75,7 @@ When invoked, **ghorgsync** performs the following steps:
1. **Load configuration** from `.ghorgsync` and validate it.
2. **Resolve authentication** and connect to the GitHub API. See [Installation](INSTALL.md#prerequisites) for configuring authentication.
3. **Fetch the organization repository list** including default branch metadata.
4. **Filter repositories** by visibility (`include_public`/`include_private`) and exclusion patterns.
4. **Filter repositories** by visibility (`include_public`/`include_private`), archived status (`include_archived`), and exclusion patterns.
5. **Scan the local directory** and classify child entries (see [Local Directory Classification](#local-directory-classification)).
6. **Clone missing repositories**.
7. **Process existing repositories** (fetch, audit, conditionally checkout and pull).
Expand Down Expand Up @@ -171,6 +172,13 @@ Audit findings are user-facing warnings, not command failures.
{: .highlight }
Hidden entries (starting with `.`) are skipped during scanning. The exception being repositories with names that start with a dot, which are valid and processed normally.

## Archived Repositories

GitHub repositories can be archived, making them read-only. **ghorgsync** treats archived repositories based on the `include_archived` configuration setting:

- **Default (`include_archived: false`):** Archived repositories are ignored entirely. They are not cloned and are not synced. If a local directory exists for an archived repository, it is classified as **excluded-but-present** and reported accordingly.
- **Opt-in (`include_archived: true`):** Archived repositories are treated like any other repository — cloned if missing, and synced (fetch/audit) if present.

## Branch Drift

A repository is in *branch drift* when its current branch differs from the default branch (as defined by GitHub metadata). Default branch names are per-repository and are never assumed.
Expand Down
15 changes: 11 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import (

// Config represents the application configuration loaded from a YAML file.
type Config struct {
Organization string `yaml:"organization"`
IncludePublic *bool `yaml:"include_public"`
IncludePrivate *bool `yaml:"include_private"`
ExcludeRepos []string `yaml:"exclude_repos"`
Organization string `yaml:"organization"`
IncludePublic *bool `yaml:"include_public"`
IncludePrivate *bool `yaml:"include_private"`
IncludeArchived *bool `yaml:"include_archived"`
ExcludeRepos []string `yaml:"exclude_repos"`

// compiledExcludes caches compiled regex patterns for ExcludeRepos.
compiledExcludes []*regexp.Regexp
Expand Down Expand Up @@ -68,6 +69,12 @@ func (c *Config) ShouldIncludePrivate() bool {
return c.IncludePrivate == nil || *c.IncludePrivate
}

// ShouldIncludeArchived returns true if archived repositories should be included.
// Defaults to false when not explicitly set.
func (c *Config) ShouldIncludeArchived() bool {
return c.IncludeArchived != nil && *c.IncludeArchived
}

// IsExcluded checks whether the given repository name matches any pattern in ExcludeRepos.
func (c *Config) IsExcluded(repoName string) bool {
// Use cached compiled patterns if available (after Validate has been called)
Expand Down
45 changes: 45 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,48 @@ func TestIsExcludedNonMatching(t *testing.T) {
t.Error("IsExcluded(my-sandbox) = true, want false (prefix pattern should not match suffix)")
}
}

func TestShouldIncludeArchivedDefault(t *testing.T) {
cfg := &Config{Organization: "my-org"}
if cfg.ShouldIncludeArchived() {
t.Error("ShouldIncludeArchived() = true, want false (default)")
}
}

func TestShouldIncludeArchivedExplicitTrue(t *testing.T) {
cfg := &Config{
Organization: "my-org",
IncludeArchived: boolPtr(true),
}
if !cfg.ShouldIncludeArchived() {
t.Error("ShouldIncludeArchived() = false, want true when explicitly set")
}
}

func TestShouldIncludeArchivedExplicitFalse(t *testing.T) {
cfg := &Config{
Organization: "my-org",
IncludeArchived: boolPtr(false),
}
if cfg.ShouldIncludeArchived() {
t.Error("ShouldIncludeArchived() = true, want false when explicitly set to false")
}
}

func TestLoadConfigWithIncludeArchived(t *testing.T) {
yaml := `
organization: my-org
include_archived: true
`
path := writeTestConfig(t, yaml)
cfg, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.IncludeArchived == nil || !*cfg.IncludeArchived {
t.Errorf("IncludeArchived = %v, want true", cfg.IncludeArchived)
}
if !cfg.ShouldIncludeArchived() {
t.Error("ShouldIncludeArchived() = false, want true")
}
}
2 changes: 2 additions & 0 deletions internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type ghRepo struct {
CloneURL string `json:"clone_url"`
DefaultBranch string `json:"default_branch"`
Private bool `json:"private"`
Archived bool `json:"archived"`
}

// ListOrgRepos lists all repositories for the given organisation.
Expand Down Expand Up @@ -98,6 +99,7 @@ func (c *Client) ListOrgRepos(org string) ([]model.RepoInfo, error) {
CloneURL: r.CloneURL,
DefaultBranch: r.DefaultBranch,
IsPrivate: r.Private,
IsArchived: r.Archived,
})
}

Expand Down
7 changes: 6 additions & 1 deletion internal/github/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"github.com/UnitVectorY-Labs/ghorgsync/internal/model"
)

// FilterRepos applies visibility and exclusion filters to the repo list.
// FilterRepos applies visibility, archived, and exclusion filters to the repo list.
func FilterRepos(repos []model.RepoInfo, cfg *config.Config) (included []model.RepoInfo, excluded []string) {
for _, r := range repos {
// Visibility filter
Expand All @@ -15,6 +15,11 @@ func FilterRepos(repos []model.RepoInfo, cfg *config.Config) (included []model.R
if !r.IsPrivate && !cfg.ShouldIncludePublic() {
continue
}
// Archived filter: skip archived repos unless explicitly included
if r.IsArchived && !cfg.ShouldIncludeArchived() {
excluded = append(excluded, r.Name)
continue
}
// Exclusion filter
if cfg.IsExcluded(r.Name) {
excluded = append(excluded, r.Name)
Expand Down
45 changes: 45 additions & 0 deletions internal/github/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ func sampleRepos() []model.RepoInfo {
}
}

func sampleReposWithArchived() []model.RepoInfo {
return []model.RepoInfo{
{Name: "public-repo", CloneURL: "https://github.com/org/public-repo.git", DefaultBranch: "main", IsPrivate: false},
{Name: "private-repo", CloneURL: "https://github.com/org/private-repo.git", DefaultBranch: "main", IsPrivate: true},
{Name: "archived-public", CloneURL: "https://github.com/org/archived-public.git", DefaultBranch: "main", IsPrivate: false, IsArchived: true},
{Name: "archived-private", CloneURL: "https://github.com/org/archived-private.git", DefaultBranch: "main", IsPrivate: true, IsArchived: true},
}
}

func TestFilterRepos_DefaultConfig(t *testing.T) {
cfg := &config.Config{Organization: "org"}
included, excluded := FilterRepos(sampleRepos(), cfg)
Expand Down Expand Up @@ -110,3 +119,39 @@ func TestFilterRepos_ExcludedListTracked(t *testing.T) {
}
}
}

func TestFilterRepos_ArchivedExcludedByDefault(t *testing.T) {
cfg := &config.Config{Organization: "org"}
included, excluded := FilterRepos(sampleReposWithArchived(), cfg)
if len(included) != 2 {
t.Errorf("expected 2 included repos (non-archived), got %d", len(included))
}
if len(excluded) != 2 {
t.Errorf("expected 2 excluded repos (archived), got %d", len(excluded))
}
for _, r := range included {
if r.IsArchived {
t.Errorf("archived repo %q should have been filtered out", r.Name)
}
}
expectedExcluded := map[string]bool{"archived-public": true, "archived-private": true}
for _, name := range excluded {
if !expectedExcluded[name] {
t.Errorf("unexpected excluded repo: %q", name)
}
}
}

func TestFilterRepos_ArchivedIncludedWhenConfigured(t *testing.T) {
cfg := &config.Config{
Organization: "org",
IncludeArchived: boolPtr(true),
}
included, excluded := FilterRepos(sampleReposWithArchived(), cfg)
if len(included) != 4 {
t.Errorf("expected 4 included repos (including archived), got %d", len(included))
}
if len(excluded) != 0 {
t.Errorf("expected 0 excluded repos, got %d", len(excluded))
}
}
1 change: 1 addition & 0 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type RepoInfo struct {
CloneURL string
DefaultBranch string
IsPrivate bool
IsArchived bool
}

// LocalClassification represents the classification of a local directory entry.
Expand Down
Loading