diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index be2190e..e0ae098 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -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. diff --git a/docs/USAGE.md b/docs/USAGE.md index b51c810..23d1731 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 @@ -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). @@ -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. diff --git a/internal/config/config.go b/internal/config/config.go index 2bf48bc..0e49093 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -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) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b87814b..6ded7fc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") +} +} diff --git a/internal/github/client.go b/internal/github/client.go index b684b5d..da0c447 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -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. @@ -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, }) } diff --git a/internal/github/filter.go b/internal/github/filter.go index 8c64611..bc05b68 100644 --- a/internal/github/filter.go +++ b/internal/github/filter.go @@ -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 @@ -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) diff --git a/internal/github/filter_test.go b/internal/github/filter_test.go index 08c1063..d9e3300 100644 --- a/internal/github/filter_test.go +++ b/internal/github/filter_test.go @@ -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) @@ -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)) +} +} diff --git a/internal/model/model.go b/internal/model/model.go index 4d1c1de..7544883 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -6,6 +6,7 @@ type RepoInfo struct { CloneURL string DefaultBranch string IsPrivate bool + IsArchived bool } // LocalClassification represents the classification of a local directory entry.