From b93293b09478b68c0b5260d56141f30bc004610b Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Tue, 1 Sep 2026 21:00:53 +0530 Subject: [PATCH 1/3] Add configurable default remote --- README.md | 6 +++- internal/cli/root.go | 18 ++++++++---- internal/cli/root_test.go | 52 ++++++++++++++++++++++++++++++++++ internal/config/config.go | 4 +++ internal/config/config_test.go | 9 ++++++ 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1af1238..c342959 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Two config files, both INI-style: ```ini [default] output = json +remote = origin [github.com] token = ghp_abc123 @@ -121,12 +122,15 @@ token-cmd = pass show forge/$FORGE_DOMAIN ```ini [default] forge-type = gitlab +remote = devrepo [gitlab.internal.dev] type = gitlab ``` -This tells forge that the project uses GitLab and that `gitlab.internal.dev` is a GitLab instance, so contributors don't each need `--forge-type` or `FORGE_HOST`. +This tells forge to use the `devrepo` git remote, that the project uses GitLab, and that `gitlab.internal.dev` is a GitLab instance, so contributors don't each need `--remote`, `--forge-type`, or `FORGE_HOST`. + +The `[default]` `remote` setting selects the git remote used when `--remote` is omitted. If neither is set, forge uses `origin`. For a self-hosted instance served over plain HTTP (a local Forgejo in Docker, say), add `scheme = http` to its section in `~/.config/forge/config`, use `forge auth login --scheme http`, or pass a full URL to `--host`/`FORGE_HOST`: diff --git a/internal/cli/root.go b/internal/cli/root.go index a60bf6b..eedfe26 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -26,13 +26,21 @@ var rootCmd = &cobra.Command{ Long: "Supports GitHub, GitLab, Gitea, Forgejo, Bitbucket Cloud, Gerrit, and Tangled through a single interface.", SilenceUsage: true, PersistentPreRun: func(cmd *cobra.Command, args []string) { - if !cmd.Flags().Changed("output") { - cfg, err := config.Load() - if err == nil && cfg != nil && cfg.Default.Output != "" { + cfg, _ := config.Load() + if cfg != nil { + if !cmd.Flags().Changed("output") && cfg.Default.Output != "" { flagOutput = cfg.Default.Output } } - resolve.SetRemote(flagRemote) + + remote := "origin" + if cfg != nil && cfg.Default.Remote != "" { + remote = cfg.Default.Remote + } + if cmd.Flags().Changed("remote") && flagRemote != "" { + remote = flagRemote + } + resolve.SetRemote(remote) resolve.SetHost(flagHost) resolve.SetForgeType(flagForgeType) }, @@ -48,7 +56,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&flagForgeType, "forge-type", "", "Force forge type: github, gitlab, gitea, forgejo, bitbucket, gerrit, tangled") rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "Force forge host (e.g. gitea.com, http://forgejo.local:3000); overrides FORGE_HOST and remote detection") rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "table", "Output format: table, json, plain") - rootCmd.PersistentFlags().StringVar(&flagRemote, "remote", "", "Git remote to use when not specifying -R (default origin)") + rootCmd.PersistentFlags().StringVar(&flagRemote, "remote", "", "Git remote to use when not specifying -R (default from config, otherwise origin)") } // notSupported wraps ErrNotSupported with a user-friendly message diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index a97a803..748968e 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -2,9 +2,13 @@ package cli import ( "errors" + "os" + "path/filepath" "testing" "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/internal/config" + "github.com/git-pkgs/forge/internal/resolve" ) func TestNotSupportedWrapsError(t *testing.T) { @@ -26,3 +30,51 @@ func TestNotSupportedPassesThrough(t *testing.T) { t.Errorf("expected original error %q, got %q", original, err) } } + +func TestRemotePrecedence(t *testing.T) { + tests := []struct { + name string + configRemote string + flagRemote string + want string + }{ + {name: "origin fallback", want: "origin"}, + {name: "config remote", configRemote: "devrepo", want: "devrepo"}, + {name: "flag overrides config", configRemote: "devrepo", flagRemote: "fork", want: "fork"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetCmd(rootCmd) + config.ResetCache() + t.Cleanup(config.ResetCache) + resolve.SetRemote("origin") + t.Cleanup(func() { resolve.SetRemote("origin") }) + + dir := t.TempDir() + t.Chdir(dir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "config")) + if tt.configRemote != "" { + configDir := filepath.Join(dir, "config", "forge") + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatal(err) + } + contents := []byte("[default]\nremote = " + tt.configRemote + "\n") + if err := os.WriteFile(filepath.Join(configDir, "config"), contents, 0o600); err != nil { + t.Fatal(err) + } + } + + if tt.flagRemote != "" { + if err := rootCmd.PersistentFlags().Set("remote", tt.flagRemote); err != nil { + t.Fatal(err) + } + } + rootCmd.PersistentPreRun(rootCmd, nil) + + if got := resolve.RemoteName(); got != tt.want { + t.Fatalf("remote = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index c7aaea9..39f0662 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,7 @@ type DefaultSection struct { Output string // table, json, plain ForgeType string // default forge type GitProtocol string // https or ssh + Remote string // default git remote name } type DomainSection struct { @@ -200,6 +201,9 @@ func loadFile(cfg *Config, path string, userConfig bool) error { if v, ok := def["forge-type"]; ok { cfg.Default.ForgeType = v } + if v, ok := def["remote"]; ok { + cfg.Default.Remote = v + } if v, ok := def["git_protocol"]; ok { p, err := parseGitProtocol(v) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0966db7..6a29b25 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -18,6 +18,7 @@ func TestParseINI(t *testing.T) { [default] output = json forge-type = gitlab +remote = devrepo [github.com] token = ghp_abc123 @@ -38,6 +39,9 @@ token = abc123 if sections[sectionDefault]["forge-type"] != "gitlab" { t.Errorf("expected forge-type=gitlab, got %q", sections[sectionDefault]["forge-type"]) } + if sections[sectionDefault]["remote"] != "devrepo" { + t.Errorf("expected remote=devrepo, got %q", sections[sectionDefault]["remote"]) + } if sections["github.com"]["token"] != "ghp_abc123" { t.Errorf("expected github.com token=ghp_abc123, got %q", sections["github.com"]["token"]) } @@ -209,6 +213,7 @@ func TestLoadMergesUserAndProject(t *testing.T) { _ = os.WriteFile(userConfig, []byte(` [default] output = json +remote = personal [github.com] token = ghp_user @@ -225,6 +230,7 @@ token = gitea_tok _ = os.WriteFile(projectConfig, []byte(` [default] forge-type = gitlab +remote = devrepo [gitea.example.com] type = forgejo @@ -250,6 +256,9 @@ token = should_be_ignored if cfg.Default.ForgeType != "gitlab" { t.Errorf("expected forge-type=gitlab, got %q", cfg.Default.ForgeType) } + if cfg.Default.Remote != "devrepo" { + t.Errorf("expected project remote to override user remote, got %q", cfg.Default.Remote) + } // User config token preserved if cfg.Domains["github.com"].Token != "ghp_user" { From 2c6268a156d0d02f090f715d5d4b1ead9bb906b5 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Tue, 1 Sep 2026 21:14:58 +0530 Subject: [PATCH 2/3] Clarify remote flag help --- internal/cli/root.go | 2 +- internal/cli/root_test.go | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index eedfe26..0d1f0b5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -56,7 +56,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&flagForgeType, "forge-type", "", "Force forge type: github, gitlab, gitea, forgejo, bitbucket, gerrit, tangled") rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "Force forge host (e.g. gitea.com, http://forgejo.local:3000); overrides FORGE_HOST and remote detection") rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "table", "Output format: table, json, plain") - rootCmd.PersistentFlags().StringVar(&flagRemote, "remote", "", "Git remote to use when not specifying -R (default from config, otherwise origin)") + rootCmd.PersistentFlags().StringVar(&flagRemote, "remote", "", "Git remote to use when --repo (-R) is not specified (default from config, otherwise origin)") } // notSupported wraps ErrNotSupported with a user-friendly message diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 748968e..c3a46a7 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -31,6 +31,18 @@ func TestNotSupportedPassesThrough(t *testing.T) { } } +func TestRemoteFlagHelp(t *testing.T) { + flag := rootCmd.PersistentFlags().Lookup("remote") + if flag == nil { + t.Fatal("remote flag not found") + } + + want := "Git remote to use when --repo (-R) is not specified (default from config, otherwise origin)" + if flag.Usage != want { + t.Errorf("remote flag help = %q, want %q", flag.Usage, want) + } +} + func TestRemotePrecedence(t *testing.T) { tests := []struct { name string From e6ad9b16649d059d0e868c570d9fa46c4014b4cc Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Wed, 2 Sep 2026 18:23:30 +0530 Subject: [PATCH 3/3] Test configured remote end to end --- internal/cli/root_test.go | 91 ++++++++++++++++++++++++++++--------- internal/resolve/resolve.go | 6 +-- 2 files changed, 73 insertions(+), 24 deletions(-) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index c3a46a7..11c488d 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1,9 +1,15 @@ package cli import ( + "encoding/json" "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" "os" "path/filepath" + "strings" "testing" "github.com/git-pkgs/forge" @@ -48,44 +54,87 @@ func TestRemotePrecedence(t *testing.T) { name string configRemote string flagRemote string - want string + wantRepo string }{ - {name: "origin fallback", want: "origin"}, - {name: "config remote", configRemote: "devrepo", want: "devrepo"}, - {name: "flag overrides config", configRemote: "devrepo", flagRemote: "fork", want: "fork"}, + {name: "origin fallback", wantRepo: "origin-owner/origin-repo"}, + {name: "config remote", configRemote: "devrepo", wantRepo: "config-owner/config-repo"}, + {name: "flag overrides config", configRemote: "devrepo", flagRemote: "fork", wantRepo: "flag-owner/flag-repo"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resetCmd(rootCmd) + t.Cleanup(func() { + resetCmd(rootCmd) + rootCmd.SetArgs(nil) + }) + resolve.ResetTestForge() + t.Cleanup(resolve.ResetTestForge) config.ResetCache() t.Cleanup(config.ResetCache) resolve.SetRemote("origin") t.Cleanup(func() { resolve.SetRemote("origin") }) + t.Setenv("FORGE_HOST", "") + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) - dir := t.TempDir() - t.Chdir(dir) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "config")) - if tt.configRemote != "" { - configDir := filepath.Join(dir, "config", "forge") - if err := os.MkdirAll(configDir, 0o700); err != nil { - t.Fatal(err) - } - contents := []byte("[default]\nremote = " + tt.configRemote + "\n") - if err := os.WriteFile(filepath.Join(configDir, "config"), contents, 0o600); err != nil { - t.Fatal(err) + requestedRepo := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/") + if strings.HasSuffix(path, "/topics") { + _ = json.NewEncoder(w).Encode(map[string]any{"topics": []string{}}) + return } + + requestedRepo <- path + parts := strings.SplitN(path, "/", 2) + _ = json.NewEncoder(w).Encode(map[string]any{ + "full_name": path, + "name": parts[1], + "owner": map[string]any{"login": parts[0]}, + }) + })) + defer server.Close() + + repoDir := setupTestRepo(t, server.URL+"/origin-owner/origin-repo.git") + mustGit(t, repoDir, "remote", "add", "devrepo", server.URL+"/config-owner/config-repo.git") + mustGit(t, repoDir, "remote", "add", "fork", server.URL+"/flag-owner/flag-repo.git") + t.Chdir(repoDir) + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + configHome := t.TempDir() + configDir := filepath.Join(configHome, "forge") + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatal(err) } + configContents := "[default]\n" + if tt.configRemote != "" { + configContents += "remote = " + tt.configRemote + "\n" + } + configContents += fmt.Sprintf("\n[%s]\ntype = gitea\nscheme = http\n", serverURL.Host) + if err := os.WriteFile(filepath.Join(configDir, "config"), []byte(configContents), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", configHome) + args := []string{"--output", "json"} if tt.flagRemote != "" { - if err := rootCmd.PersistentFlags().Set("remote", tt.flagRemote); err != nil { - t.Fatal(err) - } + args = append(args, "--remote", tt.flagRemote) + } + rootCmd.SetArgs(append(args, "repo", "view")) + stdout, err := captureStdout(t, rootCmd.Execute) + if err != nil { + t.Fatalf("root command: %v", err) } - rootCmd.PersistentPreRun(rootCmd, nil) - if got := resolve.RemoteName(); got != tt.want { - t.Fatalf("remote = %q, want %q", got, tt.want) + if got := <-requestedRepo; got != tt.wantRepo { + t.Fatalf("requested repo = %q, want %q", got, tt.wantRepo) + } + if !strings.Contains(stdout, tt.wantRepo) { + t.Fatalf("output does not contain selected repo %q: %s", tt.wantRepo, stdout) } }) } diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index a96b5c4..5605947 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -112,9 +112,9 @@ var builders = forges.ForgeBuilders{ Tangled: tangled.New, } -// Repo figures out the forge, owner, and repo name from flags or the current -// git remote. The -R flag takes precedence; otherwise we read the "origin" -// remote URL and parse it. +// Repo figures out the forge, owner, and repo name from flags or a git remote. +// The --repo (-R) flag takes precedence; otherwise it parses the remote selected +// by --remote, config, or the "origin" fallback. func Repo(flagRepo, flagForgeType string) (forge forges.Forge, owner, repo, domain string, err error) { if testForge != nil { return testForge, testOwner, testRepo, testDomain, nil