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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Two config files, both INI-style:
```ini
[default]
output = json
remote = origin

[github.com]
token = ghp_abc123
Expand Down Expand Up @@ -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`:

Expand Down
18 changes: 13 additions & 5 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand All @@ -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 --repo (-R) is not specified (default from config, otherwise origin)")
}

// notSupported wraps ErrNotSupported with a user-friendly message
Expand Down
113 changes: 113 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
package cli

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"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) {
Expand All @@ -26,3 +36,106 @@ func TestNotSupportedPassesThrough(t *testing.T) {
t.Errorf("expected original error %q, got %q", original, err)
}
}

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
configRemote string
flagRemote string
wantRepo string
}{
{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)

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 != "" {
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)
}

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)
}
})
}
}
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func TestParseINI(t *testing.T) {
[default]
output = json
forge-type = gitlab
remote = devrepo

[github.com]
token = ghp_abc123
Expand All @@ -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"])
}
Expand Down Expand Up @@ -209,6 +213,7 @@ func TestLoadMergesUserAndProject(t *testing.T) {
_ = os.WriteFile(userConfig, []byte(`
[default]
output = json
remote = personal

[github.com]
token = ghp_user
Expand All @@ -225,6 +230,7 @@ token = gitea_tok
_ = os.WriteFile(projectConfig, []byte(`
[default]
forge-type = gitlab
remote = devrepo

[gitea.example.com]
type = forgejo
Expand All @@ -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" {
Expand Down
6 changes: 3 additions & 3 deletions internal/resolve/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down