From 58e7a1cd016bf72a74c87525036aa31f5cc793f9 Mon Sep 17 00:00:00 2001 From: Rachel Dowavic Date: Fri, 20 Feb 2026 23:45:55 +1100 Subject: [PATCH 1/3] Add config file support (gitbackup init, validate, runtime loading) Add support for a gitbackup.yml configuration file as an alternative to passing all options as CLI flags. Includes three new capabilities: - gitbackup init: creates a default gitbackup.yml in the current directory - gitbackup validate: validates config file values and required env vars - Runtime loading: when gitbackup.yml exists, it is used as the base config with CLI flags overriding individual values Migration-related flags remain CLI-only. Secrets are always provided via environment variables and are never stored in the config file. Closes #166 --- .gitignore | 3 + README.md | 47 ++++++++++ config_file.go | 188 ++++++++++++++++++++++++++++++++++++++ config_file_test.go | 216 ++++++++++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 1 + main.go | 16 ++++ options.go | 103 ++++++++++++++++----- 8 files changed, 554 insertions(+), 21 deletions(-) create mode 100644 config_file.go create mode 100644 config_file_test.go diff --git a/.gitignore b/.gitignore index cd63188..5c26818 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ gitbackup dist/ testdata/**expected + +# Config file (generated by gitbackup init) +gitbackup.yml diff --git a/README.md b/README.md index 6985a2a..b10bb72 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Contact us for any custom on-prem or cloud deployment, new feature requests or e - [GitLab](#gitlab) - [Forgejo](#forgejo) - [Security and credentials](#security-and-credentials) + - [Configuration file](#configuration-file) - [Examples](#examples) - [Backing up your GitHub repositories](#backing-up-your-github-repositories) - [Backing up your GitLab repositories](#backing-up-your-gitlab-repositories) @@ -115,6 +116,52 @@ is used to clone your repositories. If `use-https-clone` is specified, private r are cloned via `https` basic auth and the token provided will be stored in the repositories' `.git/config`. +### Configuration file + +Instead of passing all options as CLI flags, you can use a ``gitbackup.yml`` configuration file. + +To create a default configuration file in the current directory: + +```lang=bash +$ gitbackup init +``` + +This creates a ``gitbackup.yml`` with default values that you can edit: + +```yaml +service: github +githost_url: "" +backup_dir: "" +ignore_private: false +ignore_fork: false +use_https_clone: false +bare: false +github: + repo_type: all + namespace_whitelist: [] +gitlab: + project_visibility: internal + project_membership_type: all +forgejo: + repo_type: user +``` + +To validate your configuration file (checks field values and required environment variables): + +```lang=bash +$ gitbackup validate +``` + +When ``gitbackup.yml`` exists in the current directory, it is automatically loaded at runtime. CLI flags override config file values, so you can use the config file for your base settings and override individual options as needed: + +```lang=bash +$ GITHUB_TOKEN=secret$token gitbackup -ignore-fork +``` + +Secrets (tokens, passwords) are not stored in the config file — they are always provided via environment variables. + +**Note:** Migration-related flags (``-github.createUserMigration``, ``-github.listUserMigrations``, etc.) are CLI-only and not supported in the config file. + ### Examples Typing ``-help`` will display the command line options that `gitbackup` recognizes: diff --git a/config_file.go b/config_file.go new file mode 100644 index 0000000..92d1dc2 --- /dev/null +++ b/config_file.go @@ -0,0 +1,188 @@ +package main + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +const defaultConfigFile = "gitbackup.yml" + +// fileConfig represents the YAML configuration file structure. +// Migration-related flags are intentionally excluded as they +// are one-off operations better suited to CLI flags. +type fileConfig struct { + Service string `yaml:"service"` + GitHostURL string `yaml:"githost_url"` + BackupDir string `yaml:"backup_dir"` + IgnorePrivate bool `yaml:"ignore_private"` + IgnoreFork bool `yaml:"ignore_fork"` + UseHTTPSClone bool `yaml:"use_https_clone"` + Bare bool `yaml:"bare"` + GitHub githubConfig `yaml:"github"` + GitLab gitlabConfig `yaml:"gitlab"` + Forgejo forgejoConfig `yaml:"forgejo"` +} + +type githubConfig struct { + RepoType string `yaml:"repo_type"` + NamespaceWhitelist []string `yaml:"namespace_whitelist"` +} + +type gitlabConfig struct { + ProjectVisibility string `yaml:"project_visibility"` + ProjectMembershipType string `yaml:"project_membership_type"` +} + +type forgejoConfig struct { + RepoType string `yaml:"repo_type"` +} + +// defaultFileConfig returns a fileConfig with the same defaults as the CLI flags +func defaultFileConfig() fileConfig { + return fileConfig{ + Service: "github", + GitHostURL: "", + BackupDir: "", + IgnorePrivate: false, + IgnoreFork: false, + UseHTTPSClone: false, + Bare: false, + GitHub: githubConfig{ + RepoType: "all", + NamespaceWhitelist: []string{}, + }, + GitLab: gitlabConfig{ + ProjectVisibility: "internal", + ProjectMembershipType: "all", + }, + Forgejo: forgejoConfig{ + RepoType: "user", + }, + } +} + +// handleInitConfig creates a default gitbackup.yml in the current directory +func handleInitConfig() error { + if _, err := os.Stat(defaultConfigFile); err == nil { + return fmt.Errorf("%s already exists", defaultConfigFile) + } + + cfg := defaultFileConfig() + data, err := yaml.Marshal(&cfg) + if err != nil { + return fmt.Errorf("error generating config: %v", err) + } + + err = os.WriteFile(defaultConfigFile, data, 0644) + if err != nil { + return fmt.Errorf("error writing %s: %v", defaultConfigFile, err) + } + + fmt.Printf("Created %s\n", defaultConfigFile) + return nil +} + +// fileConfigToAppConfig converts a fileConfig into an appConfig. +// Migration-related fields are left at their zero values since they +// are CLI-only flags. +func fileConfigToAppConfig(fc *fileConfig) *appConfig { + return &appConfig{ + service: fc.Service, + gitHostURL: fc.GitHostURL, + backupDir: fc.BackupDir, + ignorePrivate: fc.IgnorePrivate, + ignoreFork: fc.IgnoreFork, + useHTTPSClone: fc.UseHTTPSClone, + bare: fc.Bare, + githubRepoType: fc.GitHub.RepoType, + githubNamespaceWhitelist: fc.GitHub.NamespaceWhitelist, + gitlabProjectVisibility: fc.GitLab.ProjectVisibility, + gitlabProjectMembershipType: fc.GitLab.ProjectMembershipType, + forgejoRepoType: fc.Forgejo.RepoType, + } +} + +// loadConfigFile reads and parses gitbackup.yml from the current directory +func loadConfigFile() (*fileConfig, error) { + data, err := os.ReadFile(defaultConfigFile) + if err != nil { + return nil, fmt.Errorf("error reading %s: %v", defaultConfigFile, err) + } + + var cfg fileConfig + err = yaml.Unmarshal(data, &cfg) + if err != nil { + return nil, fmt.Errorf("error parsing %s: %v", defaultConfigFile, err) + } + return &cfg, nil +} + +// handleValidateConfig reads gitbackup.yml and validates its contents +func handleValidateConfig() error { + cfg, err := loadConfigFile() + if err != nil { + return err + } + + var errors []string + + // Validate service + if _, ok := knownServices[cfg.Service]; !ok { + errors = append(errors, fmt.Sprintf("invalid service: %q (must be github, gitlab, bitbucket, or forgejo)", cfg.Service)) + } + + // Validate service-specific field values + switch cfg.Service { + case "github": + if !contains([]string{"all", "owner", "member", "starred"}, cfg.GitHub.RepoType) { + errors = append(errors, fmt.Sprintf("invalid github.repo_type: %q (must be all, owner, member, or starred)", cfg.GitHub.RepoType)) + } + case "gitlab": + if !contains([]string{"internal", "public", "private"}, cfg.GitLab.ProjectVisibility) { + errors = append(errors, fmt.Sprintf("invalid gitlab.project_visibility: %q (must be internal, public, or private)", cfg.GitLab.ProjectVisibility)) + } + if !validGitlabProjectMembership(cfg.GitLab.ProjectMembershipType) { + errors = append(errors, fmt.Sprintf("invalid gitlab.project_membership_type: %q (must be all, owner, member, or starred)", cfg.GitLab.ProjectMembershipType)) + } + case "forgejo": + if !contains([]string{"user", "starred"}, cfg.Forgejo.RepoType) { + errors = append(errors, fmt.Sprintf("invalid forgejo.repo_type: %q (must be user or starred)", cfg.Forgejo.RepoType)) + } + } + + // Validate required environment variables + switch cfg.Service { + case "github": + if os.Getenv("GITHUB_TOKEN") == "" { + errors = append(errors, "GITHUB_TOKEN environment variable not set") + } + case "gitlab": + if os.Getenv("GITLAB_TOKEN") == "" { + errors = append(errors, "GITLAB_TOKEN environment variable not set") + } + case "bitbucket": + if os.Getenv("BITBUCKET_USERNAME") == "" { + errors = append(errors, "BITBUCKET_USERNAME environment variable not set") + } + if os.Getenv("BITBUCKET_TOKEN") == "" && os.Getenv("BITBUCKET_PASSWORD") == "" { + errors = append(errors, "BITBUCKET_TOKEN or BITBUCKET_PASSWORD environment variable must be set") + } + case "forgejo": + if os.Getenv("FORGEJO_TOKEN") == "" { + errors = append(errors, "FORGEJO_TOKEN environment variable not set") + } + } + + if len(errors) > 0 { + fmt.Println("Validation errors:") + for _, e := range errors { + fmt.Printf(" - %s\n", e) + } + return fmt.Errorf("config validation failed") + } + + fmt.Printf("%s is valid\n", defaultConfigFile) + return nil +} diff --git a/config_file_test.go b/config_file_test.go new file mode 100644 index 0000000..0927474 --- /dev/null +++ b/config_file_test.go @@ -0,0 +1,216 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestHandleInitConfig(t *testing.T) { + // Work in a temp directory + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origDir) + + // First call should create the file + err := handleInitConfig() + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + configPath := filepath.Join(tmpDir, defaultConfigFile) + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Fatal("Expected gitbackup.yml to be created") + } + + // Verify the file is valid YAML that parses into fileConfig + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("Error reading config file: %v", err) + } + + var cfg fileConfig + err = yaml.Unmarshal(data, &cfg) + if err != nil { + t.Fatalf("Error parsing config file: %v", err) + } + + // Verify defaults match + if cfg.Service != "github" { + t.Errorf("Expected service to be 'github', got: %v", cfg.Service) + } + if cfg.GitHub.RepoType != "all" { + t.Errorf("Expected github.repo_type to be 'all', got: %v", cfg.GitHub.RepoType) + } + if cfg.GitLab.ProjectVisibility != "internal" { + t.Errorf("Expected gitlab.project_visibility to be 'internal', got: %v", cfg.GitLab.ProjectVisibility) + } + + // Second call should error because file already exists + err = handleInitConfig() + if err == nil { + t.Fatal("Expected error when config file already exists") + } +} + +func TestHandleValidateConfig(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origDir) + + // Validate should fail if no config file exists + err := handleValidateConfig() + if err == nil { + t.Fatal("Expected error when config file doesn't exist") + } + + // Create a valid config and set the required env var + err = handleInitConfig() + if err != nil { + t.Fatalf("Expected no error creating config, got: %v", err) + } + + os.Setenv("GITHUB_TOKEN", "testtoken") + defer os.Unsetenv("GITHUB_TOKEN") + + err = handleValidateConfig() + if err != nil { + t.Fatalf("Expected valid config, got: %v", err) + } +} + +func TestHandleValidateConfigInvalidService(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origDir) + + // Write a config with an invalid service + os.WriteFile(defaultConfigFile, []byte("service: notaservice\n"), 0644) + + err := handleValidateConfig() + if err == nil { + t.Fatal("Expected validation error for invalid service") + } +} + +func TestHandleValidateConfigMissingEnvVar(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origDir) + + // Write a valid gitlab config but don't set GITLAB_TOKEN + os.WriteFile(defaultConfigFile, []byte("service: gitlab\ngitlab:\n project_visibility: internal\n project_membership_type: all\n"), 0644) + os.Unsetenv("GITLAB_TOKEN") + + err := handleValidateConfig() + if err == nil { + t.Fatal("Expected validation error for missing GITLAB_TOKEN") + } +} + +func TestInitConfigWithConfigFile(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origDir) + + // Write a config file with specific values + os.WriteFile(defaultConfigFile, []byte("service: gitlab\nignore_fork: true\nuse_https_clone: true\ngitlab:\n project_visibility: private\n project_membership_type: owner\n"), 0644) + os.Setenv("GITLAB_TOKEN", "testtoken") + defer os.Unsetenv("GITLAB_TOKEN") + + // initConfig with no CLI flags should use config file values + c, err := initConfig([]string{}) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if c.service != "gitlab" { + t.Errorf("Expected service 'gitlab', got: %v", c.service) + } + if !c.ignoreFork { + t.Error("Expected ignore_fork to be true from config file") + } + if !c.useHTTPSClone { + t.Error("Expected use_https_clone to be true from config file") + } + if c.gitlabProjectVisibility != "private" { + t.Errorf("Expected project_visibility 'private', got: %v", c.gitlabProjectVisibility) + } + if c.gitlabProjectMembershipType != "owner" { + t.Errorf("Expected project_membership_type 'owner', got: %v", c.gitlabProjectMembershipType) + } +} + +func TestInitConfigCLIOverridesConfigFile(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origDir) + + // Config file says gitlab with ignore_fork true + os.WriteFile(defaultConfigFile, []byte("service: gitlab\nignore_fork: true\ngitlab:\n project_visibility: private\n project_membership_type: all\n"), 0644) + os.Setenv("GITLAB_TOKEN", "testtoken") + defer os.Unsetenv("GITLAB_TOKEN") + + // CLI flag overrides service to github + c, err := initConfig([]string{"-service", "github"}) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // service should be overridden by CLI flag + if c.service != "github" { + t.Errorf("Expected service 'github' from CLI flag, got: %v", c.service) + } + // ignore_fork should still be true from config file (not overridden) + if !c.ignoreFork { + t.Error("Expected ignore_fork to be true from config file") + } +} + +func TestInitConfigNoConfigFile(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origDir) + + // No config file — should behave exactly as before + c, err := initConfig([]string{"-service", "github"}) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if c.service != "github" { + t.Errorf("Expected service 'github', got: %v", c.service) + } + // Defaults should be the flag defaults + if c.ignoreFork { + t.Error("Expected ignore_fork to be false by default") + } + if c.githubRepoType != "all" { + t.Errorf("Expected github.repoType 'all', got: %v", c.githubRepoType) + } +} + +func TestHandleValidateConfigInvalidRepoType(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origDir) + + os.WriteFile(defaultConfigFile, []byte("service: github\ngithub:\n repo_type: badvalue\n"), 0644) + os.Setenv("GITHUB_TOKEN", "testtoken") + defer os.Unsetenv("GITHUB_TOKEN") + + err := handleValidateConfig() + if err == nil { + t.Fatal("Expected validation error for invalid repo_type") + } +} diff --git a/go.mod b/go.mod index 9cf8280..69011d6 100644 --- a/go.mod +++ b/go.mod @@ -40,4 +40,5 @@ require ( golang.org/x/sys v0.34.0 // indirect golang.org/x/term v0.33.0 // indirect golang.org/x/time v0.12.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 18bdcae..028dfd2 100644 --- a/go.sum +++ b/go.sum @@ -113,6 +113,7 @@ golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/main.go b/main.go index 65778ba..2952050 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,22 @@ var knownServices = map[string]string{ func main() { + // Handle subcommands before flag parsing + if len(os.Args) > 1 { + switch os.Args[1] { + case "init": + if err := handleInitConfig(); err != nil { + log.Fatal(err) + } + return + case "validate": + if err := handleValidateConfig(); err != nil { + log.Fatal(err) + } + return + } + } + c, err := initConfig(os.Args[1:]) if err != nil { log.Fatal(err) diff --git a/options.go b/options.go index 674532a..fbada7c 100644 --- a/options.go +++ b/options.go @@ -3,50 +3,65 @@ package main import ( "errors" "flag" + "os" "strings" ) -// initConfig initializes and parses command-line flags into an appConfig struct +// initConfig initializes and parses command-line flags into an appConfig struct. +// If a gitbackup.yml exists in the current directory, it is loaded first and +// CLI flags override any values from the config file. func initConfig(args []string) (*appConfig, error) { - var githubNamespaceWhitelistString string + // Try to load config file as the base configuration var c appConfig + configFileLoaded := false + if _, err := os.Stat(defaultConfigFile); err == nil { + fc, err := loadConfigFile() + if err != nil { + return nil, err + } + c = *fileConfigToAppConfig(fc) + configFileLoaded = true + } + + var githubNamespaceWhitelistString string + var flagConfig appConfig fs := flag.NewFlagSet("gitbackup", flag.ExitOnError) // Generic flags - fs.StringVar(&c.service, "service", "", "Git Hosted Service Name (github/gitlab/bitbucket/forgejo)") - fs.StringVar(&c.gitHostURL, "githost.url", "", "DNS of the custom Git host") - fs.StringVar(&c.backupDir, "backupdir", "", "Backup directory") - fs.BoolVar(&c.ignorePrivate, "ignore-private", false, "Ignore private repositories/projects") - fs.BoolVar(&c.ignoreFork, "ignore-fork", false, "Ignore repositories which are forks") - fs.BoolVar(&c.useHTTPSClone, "use-https-clone", false, "Use HTTPS for cloning instead of SSH") - fs.BoolVar(&c.bare, "bare", false, "Clone bare repositories") + fs.StringVar(&flagConfig.service, "service", "", "Git Hosted Service Name (github/gitlab/bitbucket/forgejo)") + fs.StringVar(&flagConfig.gitHostURL, "githost.url", "", "DNS of the custom Git host") + fs.StringVar(&flagConfig.backupDir, "backupdir", "", "Backup directory") + fs.BoolVar(&flagConfig.ignorePrivate, "ignore-private", false, "Ignore private repositories/projects") + fs.BoolVar(&flagConfig.ignoreFork, "ignore-fork", false, "Ignore repositories which are forks") + fs.BoolVar(&flagConfig.useHTTPSClone, "use-https-clone", false, "Use HTTPS for cloning instead of SSH") + fs.BoolVar(&flagConfig.bare, "bare", false, "Clone bare repositories") // GitHub specific flags - fs.StringVar(&c.githubRepoType, "github.repoType", "all", "Repo types to backup (all, owner, member, starred)") + fs.StringVar(&flagConfig.githubRepoType, "github.repoType", "all", "Repo types to backup (all, owner, member, starred)") fs.StringVar( &githubNamespaceWhitelistString, "github.namespaceWhitelist", "", "Organizations/Users from where we should clone (separate each value by a comma: 'user1,org2')", ) - fs.BoolVar(&c.githubCreateUserMigration, "github.createUserMigration", false, "Download user data") + fs.BoolVar(&flagConfig.githubCreateUserMigration, "github.createUserMigration", false, "Download user data") fs.BoolVar( - &c.githubCreateUserMigrationRetry, "github.createUserMigrationRetry", true, + &flagConfig.githubCreateUserMigrationRetry, "github.createUserMigrationRetry", true, "Retry creating the GitHub user migration if we get an error", ) fs.IntVar( - &c.githubCreateUserMigrationRetryMax, "github.createUserMigrationRetryMax", + &flagConfig.githubCreateUserMigrationRetryMax, "github.createUserMigrationRetryMax", defaultMaxUserMigrationRetry, "Number of retries to attempt for creating GitHub user migration", ) fs.BoolVar( - &c.githubListUserMigrations, + &flagConfig.githubListUserMigrations, "github.listUserMigrations", false, "List available user migrations", ) fs.BoolVar( - &c.githubWaitForMigrationComplete, + &flagConfig.githubWaitForMigrationComplete, "github.waitForUserMigration", true, "Wait for migration to complete", @@ -54,29 +69,75 @@ func initConfig(args []string) (*appConfig, error) { // Gitlab specific flags fs.StringVar( - &c.gitlabProjectVisibility, + &flagConfig.gitlabProjectVisibility, "gitlab.projectVisibility", "internal", "Visibility level of Projects to clone (internal, public, private)", ) fs.StringVar( - &c.gitlabProjectMembershipType, + &flagConfig.gitlabProjectMembershipType, "gitlab.projectMembershipType", "all", "Project type to clone (all, owner, member, starred)", ) // Forgejo specific flags - fs.StringVar(&c.forgejoRepoType, "forgejo.repoType", "user", "Repo types to backup (user, starred)") + fs.StringVar(&flagConfig.forgejoRepoType, "forgejo.repoType", "user", "Repo types to backup (user, starred)") err := fs.Parse(args) if err != nil && !errors.Is(err, flag.ErrHelp) { return nil, err } - // Parse namespace whitelist - if len(githubNamespaceWhitelistString) > 0 { - c.githubNamespaceWhitelist = strings.Split(githubNamespaceWhitelistString, ",") + if configFileLoaded { + // Only override config file values with flags that were explicitly set + fs.Visit(func(f *flag.Flag) { + switch f.Name { + case "service": + c.service = flagConfig.service + case "githost.url": + c.gitHostURL = flagConfig.gitHostURL + case "backupdir": + c.backupDir = flagConfig.backupDir + case "ignore-private": + c.ignorePrivate = flagConfig.ignorePrivate + case "ignore-fork": + c.ignoreFork = flagConfig.ignoreFork + case "use-https-clone": + c.useHTTPSClone = flagConfig.useHTTPSClone + case "bare": + c.bare = flagConfig.bare + case "github.repoType": + c.githubRepoType = flagConfig.githubRepoType + case "github.namespaceWhitelist": + // handled below + case "gitlab.projectVisibility": + c.gitlabProjectVisibility = flagConfig.gitlabProjectVisibility + case "gitlab.projectMembershipType": + c.gitlabProjectMembershipType = flagConfig.gitlabProjectMembershipType + case "forgejo.repoType": + c.forgejoRepoType = flagConfig.forgejoRepoType + } + }) + + // Migration flags are always from CLI (not in config file) + c.githubCreateUserMigration = flagConfig.githubCreateUserMigration + c.githubCreateUserMigrationRetry = flagConfig.githubCreateUserMigrationRetry + c.githubCreateUserMigrationRetryMax = flagConfig.githubCreateUserMigrationRetryMax + c.githubListUserMigrations = flagConfig.githubListUserMigrations + c.githubWaitForMigrationComplete = flagConfig.githubWaitForMigrationComplete + + // Parse namespace whitelist if explicitly set + if len(githubNamespaceWhitelistString) > 0 { + c.githubNamespaceWhitelist = strings.Split(githubNamespaceWhitelistString, ",") + } + } else { + // No config file — use flags directly (original behavior) + c = flagConfig + if len(githubNamespaceWhitelistString) > 0 { + c.githubNamespaceWhitelist = strings.Split(githubNamespaceWhitelistString, ",") + } } + c.backupDir = setupBackupDir(&c.backupDir, &c.service, &c.gitHostURL) return &c, nil } From 971b3bfa653813198df297a3da9d9c7c41789430 Mon Sep 17 00:00:00 2001 From: Rachel Dowavic Date: Mon, 23 Feb 2026 16:52:20 +1100 Subject: [PATCH 2/3] Use OS config directory for config file, add --config flag and --help Address review feedback: - Store config file in OS-specific config directory instead of the current working directory (e.g. ~/.config/gitbackup/gitbackup.yml on Linux, ~/Library/Application Support/gitbackup/gitbackup.yml on macOS) - Add --config flag to gitbackup init, validate, and the main command to allow specifying a custom config file location - Add --help support for init and validate subcommands - Update tests to use explicit config paths instead of os.Chdir - Update README with new config locations and examples --- README.md | 29 ++++++++++++- config_file.go | 79 ++++++++++++++++++++++++++++------- config_file_test.go | 81 ++++++++++++++++-------------------- main.go | 22 +++++++++- options.go | 36 +++++++++------- testdata/TestCliUsage.golden | 2 + 6 files changed, 171 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index b10bb72..530789e 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,24 @@ are cloned via `https` basic auth and the token provided will be stored in the Instead of passing all options as CLI flags, you can use a ``gitbackup.yml`` configuration file. -To create a default configuration file in the current directory: +The config file is stored in the OS-specific configuration directory: + +- **Linux:** ``$XDG_CONFIG_HOME/gitbackup/gitbackup.yml`` or ``~/.config/gitbackup/gitbackup.yml`` +- **macOS:** ``~/Library/Application Support/gitbackup/gitbackup.yml`` +- **Windows:** ``%AppData%/gitbackup/gitbackup.yml`` + +To create a default configuration file: ```lang=bash $ gitbackup init ``` +To create it at a custom location: + +```lang=bash +$ gitbackup init --config /path/to/gitbackup.yml +``` + This creates a ``gitbackup.yml`` with default values that you can edit: ```yaml @@ -152,12 +164,25 @@ To validate your configuration file (checks field values and required environmen $ gitbackup validate ``` -When ``gitbackup.yml`` exists in the current directory, it is automatically loaded at runtime. CLI flags override config file values, so you can use the config file for your base settings and override individual options as needed: +To see available options for a subcommand: + +```lang=bash +$ gitbackup init --help +$ gitbackup validate --help +``` + +The config file is automatically loaded at runtime from the default location. CLI flags override config file values, so you can use the config file for your base settings and override individual options as needed: ```lang=bash $ GITHUB_TOKEN=secret$token gitbackup -ignore-fork ``` +To use a config file at a custom location: + +```lang=bash +$ GITHUB_TOKEN=secret$token gitbackup -config /path/to/gitbackup.yml +``` + Secrets (tokens, passwords) are not stored in the config file — they are always provided via environment variables. **Note:** Migration-related flags (``-github.createUserMigration``, ``-github.listUserMigrations``, etc.) are CLI-only and not supported in the config file. diff --git a/config_file.go b/config_file.go index 92d1dc2..f1ab711 100644 --- a/config_file.go +++ b/config_file.go @@ -3,12 +3,35 @@ package main import ( "fmt" "os" + "path/filepath" "gopkg.in/yaml.v3" ) const defaultConfigFile = "gitbackup.yml" +// defaultConfigPath returns the OS-specific default path for the config file. +// On Linux: ~/.config/gitbackup/gitbackup.yml +// On macOS: ~/Library/Application Support/gitbackup/gitbackup.yml +// On Windows: %AppData%/gitbackup/gitbackup.yml +func defaultConfigPath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("unable to determine config directory: %v", err) + } + return filepath.Join(configDir, "gitbackup", defaultConfigFile), nil +} + +// resolveConfigPath returns the config path to use. +// If configPath is non-empty, it is returned as-is. +// Otherwise, the OS-specific default path is returned. +func resolveConfigPath(configPath string) (string, error) { + if configPath != "" { + return configPath, nil + } + return defaultConfigPath() +} + // fileConfig represents the YAML configuration file structure. // Migration-related flags are intentionally excluded as they // are one-off operations better suited to CLI flags. @@ -63,10 +86,22 @@ func defaultFileConfig() fileConfig { } } -// handleInitConfig creates a default gitbackup.yml in the current directory -func handleInitConfig() error { - if _, err := os.Stat(defaultConfigFile); err == nil { - return fmt.Errorf("%s already exists", defaultConfigFile) +// handleInitConfig creates a default gitbackup.yml at the given path, +// or at the OS-specific default location if configPath is empty. +func handleInitConfig(configPath string) error { + path, err := resolveConfigPath(configPath) + if err != nil { + return err + } + + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("%s already exists", path) + } + + // Create parent directory if it doesn't exist + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("error creating directory %s: %v", dir, err) } cfg := defaultFileConfig() @@ -75,12 +110,12 @@ func handleInitConfig() error { return fmt.Errorf("error generating config: %v", err) } - err = os.WriteFile(defaultConfigFile, data, 0644) + err = os.WriteFile(path, data, 0644) if err != nil { - return fmt.Errorf("error writing %s: %v", defaultConfigFile, err) + return fmt.Errorf("error writing %s: %v", path, err) } - fmt.Printf("Created %s\n", defaultConfigFile) + fmt.Printf("Created %s\n", path) return nil } @@ -104,24 +139,36 @@ func fileConfigToAppConfig(fc *fileConfig) *appConfig { } } -// loadConfigFile reads and parses gitbackup.yml from the current directory -func loadConfigFile() (*fileConfig, error) { - data, err := os.ReadFile(defaultConfigFile) +// loadConfigFile reads and parses the config file at the given path, +// or at the OS-specific default location if configPath is empty. +func loadConfigFile(configPath string) (*fileConfig, error) { + path, err := resolveConfigPath(configPath) + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("error reading %s: %v", defaultConfigFile, err) + return nil, fmt.Errorf("error reading %s: %v", path, err) } var cfg fileConfig err = yaml.Unmarshal(data, &cfg) if err != nil { - return nil, fmt.Errorf("error parsing %s: %v", defaultConfigFile, err) + return nil, fmt.Errorf("error parsing %s: %v", path, err) } return &cfg, nil } -// handleValidateConfig reads gitbackup.yml and validates its contents -func handleValidateConfig() error { - cfg, err := loadConfigFile() +// handleValidateConfig reads the config file and validates its contents. +// If configPath is empty, the OS-specific default location is used. +func handleValidateConfig(configPath string) error { + path, err := resolveConfigPath(configPath) + if err != nil { + return err + } + + cfg, err := loadConfigFile(configPath) if err != nil { return err } @@ -183,6 +230,6 @@ func handleValidateConfig() error { return fmt.Errorf("config validation failed") } - fmt.Printf("%s is valid\n", defaultConfigFile) + fmt.Printf("%s is valid\n", path) return nil } diff --git a/config_file_test.go b/config_file_test.go index 0927474..7d261e5 100644 --- a/config_file_test.go +++ b/config_file_test.go @@ -9,19 +9,15 @@ import ( ) func TestHandleInitConfig(t *testing.T) { - // Work in a temp directory tmpDir := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(origDir) + configPath := filepath.Join(tmpDir, defaultConfigFile) // First call should create the file - err := handleInitConfig() + err := handleInitConfig(configPath) if err != nil { t.Fatalf("Expected no error, got: %v", err) } - configPath := filepath.Join(tmpDir, defaultConfigFile) if _, err := os.Stat(configPath); os.IsNotExist(err) { t.Fatal("Expected gitbackup.yml to be created") } @@ -50,26 +46,38 @@ func TestHandleInitConfig(t *testing.T) { } // Second call should error because file already exists - err = handleInitConfig() + err = handleInitConfig(configPath) if err == nil { t.Fatal("Expected error when config file already exists") } } +func TestHandleInitConfigCreatesParentDir(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "subdir", "nested", defaultConfigFile) + + err := handleInitConfig(configPath) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Fatal("Expected config file to be created in nested directory") + } +} + func TestHandleValidateConfig(t *testing.T) { tmpDir := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(origDir) + configPath := filepath.Join(tmpDir, defaultConfigFile) // Validate should fail if no config file exists - err := handleValidateConfig() + err := handleValidateConfig(configPath) if err == nil { t.Fatal("Expected error when config file doesn't exist") } // Create a valid config and set the required env var - err = handleInitConfig() + err = handleInitConfig(configPath) if err != nil { t.Fatalf("Expected no error creating config, got: %v", err) } @@ -77,7 +85,7 @@ func TestHandleValidateConfig(t *testing.T) { os.Setenv("GITHUB_TOKEN", "testtoken") defer os.Unsetenv("GITHUB_TOKEN") - err = handleValidateConfig() + err = handleValidateConfig(configPath) if err != nil { t.Fatalf("Expected valid config, got: %v", err) } @@ -85,14 +93,12 @@ func TestHandleValidateConfig(t *testing.T) { func TestHandleValidateConfigInvalidService(t *testing.T) { tmpDir := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(origDir) + configPath := filepath.Join(tmpDir, defaultConfigFile) // Write a config with an invalid service - os.WriteFile(defaultConfigFile, []byte("service: notaservice\n"), 0644) + os.WriteFile(configPath, []byte("service: notaservice\n"), 0644) - err := handleValidateConfig() + err := handleValidateConfig(configPath) if err == nil { t.Fatal("Expected validation error for invalid service") } @@ -100,15 +106,13 @@ func TestHandleValidateConfigInvalidService(t *testing.T) { func TestHandleValidateConfigMissingEnvVar(t *testing.T) { tmpDir := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(origDir) + configPath := filepath.Join(tmpDir, defaultConfigFile) // Write a valid gitlab config but don't set GITLAB_TOKEN - os.WriteFile(defaultConfigFile, []byte("service: gitlab\ngitlab:\n project_visibility: internal\n project_membership_type: all\n"), 0644) + os.WriteFile(configPath, []byte("service: gitlab\ngitlab:\n project_visibility: internal\n project_membership_type: all\n"), 0644) os.Unsetenv("GITLAB_TOKEN") - err := handleValidateConfig() + err := handleValidateConfig(configPath) if err == nil { t.Fatal("Expected validation error for missing GITLAB_TOKEN") } @@ -116,17 +120,15 @@ func TestHandleValidateConfigMissingEnvVar(t *testing.T) { func TestInitConfigWithConfigFile(t *testing.T) { tmpDir := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(origDir) + configPath := filepath.Join(tmpDir, defaultConfigFile) // Write a config file with specific values - os.WriteFile(defaultConfigFile, []byte("service: gitlab\nignore_fork: true\nuse_https_clone: true\ngitlab:\n project_visibility: private\n project_membership_type: owner\n"), 0644) + os.WriteFile(configPath, []byte("service: gitlab\nignore_fork: true\nuse_https_clone: true\ngitlab:\n project_visibility: private\n project_membership_type: owner\n"), 0644) os.Setenv("GITLAB_TOKEN", "testtoken") defer os.Unsetenv("GITLAB_TOKEN") - // initConfig with no CLI flags should use config file values - c, err := initConfig([]string{}) + // initConfig with --config flag should use config file values + c, err := initConfig([]string{"-config", configPath}) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -150,17 +152,15 @@ func TestInitConfigWithConfigFile(t *testing.T) { func TestInitConfigCLIOverridesConfigFile(t *testing.T) { tmpDir := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(origDir) + configPath := filepath.Join(tmpDir, defaultConfigFile) // Config file says gitlab with ignore_fork true - os.WriteFile(defaultConfigFile, []byte("service: gitlab\nignore_fork: true\ngitlab:\n project_visibility: private\n project_membership_type: all\n"), 0644) + os.WriteFile(configPath, []byte("service: gitlab\nignore_fork: true\ngitlab:\n project_visibility: private\n project_membership_type: all\n"), 0644) os.Setenv("GITLAB_TOKEN", "testtoken") defer os.Unsetenv("GITLAB_TOKEN") // CLI flag overrides service to github - c, err := initConfig([]string{"-service", "github"}) + c, err := initConfig([]string{"-config", configPath, "-service", "github"}) if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -176,11 +176,6 @@ func TestInitConfigCLIOverridesConfigFile(t *testing.T) { } func TestInitConfigNoConfigFile(t *testing.T) { - tmpDir := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(origDir) - // No config file — should behave exactly as before c, err := initConfig([]string{"-service", "github"}) if err != nil { @@ -201,15 +196,13 @@ func TestInitConfigNoConfigFile(t *testing.T) { func TestHandleValidateConfigInvalidRepoType(t *testing.T) { tmpDir := t.TempDir() - origDir, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(origDir) + configPath := filepath.Join(tmpDir, defaultConfigFile) - os.WriteFile(defaultConfigFile, []byte("service: github\ngithub:\n repo_type: badvalue\n"), 0644) + os.WriteFile(configPath, []byte("service: github\ngithub:\n repo_type: badvalue\n"), 0644) os.Setenv("GITHUB_TOKEN", "testtoken") defer os.Unsetenv("GITHUB_TOKEN") - err := handleValidateConfig() + err := handleValidateConfig(configPath) if err == nil { t.Fatal("Expected validation error for invalid repo_type") } diff --git a/main.go b/main.go index 2952050..0a567c0 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "flag" + "fmt" "log" "os" ) @@ -24,18 +26,34 @@ var knownServices = map[string]string{ "forgejo": "codeberg.org", } +// parseSubcommandFlags parses --config and --help flags for a subcommand. +func parseSubcommandFlags(name, description string, args []string) string { + var configPath string + fs := flag.NewFlagSet("gitbackup "+name, flag.ExitOnError) + fs.StringVar(&configPath, "config", "", "Path to config file (default: OS config directory)") + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: gitbackup %s [--config path]\n\n", name) + fmt.Fprintf(os.Stderr, "%s\n\n", description) + fs.PrintDefaults() + } + fs.Parse(args) + return configPath +} + func main() { // Handle subcommands before flag parsing if len(os.Args) > 1 { switch os.Args[1] { case "init": - if err := handleInitConfig(); err != nil { + configPath := parseSubcommandFlags("init", "Create a default gitbackup.yml configuration file.", os.Args[2:]) + if err := handleInitConfig(configPath); err != nil { log.Fatal(err) } return case "validate": - if err := handleValidateConfig(); err != nil { + configPath := parseSubcommandFlags("validate", "Validate the gitbackup.yml configuration file.", os.Args[2:]) + if err := handleValidateConfig(configPath); err != nil { log.Fatal(err) } return diff --git a/options.go b/options.go index fbada7c..8a21352 100644 --- a/options.go +++ b/options.go @@ -8,27 +8,19 @@ import ( ) // initConfig initializes and parses command-line flags into an appConfig struct. -// If a gitbackup.yml exists in the current directory, it is loaded first and -// CLI flags override any values from the config file. +// If a config file exists at the default or specified location, it is loaded +// first and CLI flags override any values from the config file. func initConfig(args []string) (*appConfig, error) { - // Try to load config file as the base configuration - var c appConfig - configFileLoaded := false - if _, err := os.Stat(defaultConfigFile); err == nil { - fc, err := loadConfigFile() - if err != nil { - return nil, err - } - c = *fileConfigToAppConfig(fc) - configFileLoaded = true - } - var githubNamespaceWhitelistString string + var configPath string var flagConfig appConfig fs := flag.NewFlagSet("gitbackup", flag.ExitOnError) + // Config file flag + fs.StringVar(&configPath, "config", "", "Path to config file (default: OS config directory)") + // Generic flags fs.StringVar(&flagConfig.service, "service", "", "Git Hosted Service Name (github/gitlab/bitbucket/forgejo)") fs.StringVar(&flagConfig.gitHostURL, "githost.url", "", "DNS of the custom Git host") @@ -88,6 +80,22 @@ func initConfig(args []string) (*appConfig, error) { return nil, err } + // Try to load config file as the base configuration + var c appConfig + configFileLoaded := false + + resolvedPath, pathErr := resolveConfigPath(configPath) + if pathErr == nil { + if _, err := os.Stat(resolvedPath); err == nil { + fc, err := loadConfigFile(configPath) + if err != nil { + return nil, err + } + c = *fileConfigToAppConfig(fc) + configFileLoaded = true + } + } + if configFileLoaded { // Only override config file values with flags that were explicitly set fs.Visit(func(f *flag.Flag) { diff --git a/testdata/TestCliUsage.golden b/testdata/TestCliUsage.golden index 9058447..43b3caa 100644 --- a/testdata/TestCliUsage.golden +++ b/testdata/TestCliUsage.golden @@ -3,6 +3,8 @@ Usage of gitbackup: Backup directory -bare Clone bare repositories + -config string + Path to config file (default: OS config directory) -forgejo.repoType string Repo types to backup (user, starred) (default "user") -githost.url string From 02fc30a5f02d038264fb1b88fd20ce70d2e466a5 Mon Sep 17 00:00:00 2001 From: Rachel Dowavic Date: Mon, 23 Feb 2026 17:30:08 +1100 Subject: [PATCH 3/3] Update Windows golden file with --config flag --- testdata/TestCliUsage.golden.windows | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testdata/TestCliUsage.golden.windows b/testdata/TestCliUsage.golden.windows index 9058447..43b3caa 100644 --- a/testdata/TestCliUsage.golden.windows +++ b/testdata/TestCliUsage.golden.windows @@ -3,6 +3,8 @@ Usage of gitbackup: Backup directory -bare Clone bare repositories + -config string + Path to config file (default: OS config directory) -forgejo.repoType string Repo types to backup (user, starred) (default "user") -githost.url string