Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ 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`.

For a self-hosted instance served over plain HTTP (a local Forgejo in Docker, say), add `scheme = http` to its section or pass a full URL to `--host`/`FORGE_HOST`:

```ini
[172.30.0.10:3000]
type = forgejo
scheme = http
```

Precedence from highest to lowest: CLI flags, environment variables, `.forge`, `~/.config/forge/config`, built-in defaults.

## Library
Expand Down
5 changes: 3 additions & 2 deletions detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import (

// DetectForgeType probes a domain to identify which forge software it runs.
// It checks HTTP response headers first, then falls back to API endpoints.
// If hc is nil, http.DefaultClient is used.
// The domain may include an http:// or https:// prefix; without one, https is
// assumed. If hc is nil, http.DefaultClient is used.
func DetectForgeType(ctx context.Context, domain string, hc ...*http.Client) (ForgeType, error) {
client := http.DefaultClient
if len(hc) > 0 && hc[0] != nil {
client = hc[0]
}
baseURL := "https://" + domain
baseURL, _ := normalizeBaseURL(domain)

ft, err := detectFromHeaders(ctx, client, baseURL)
if err != nil {
Expand Down
21 changes: 18 additions & 3 deletions forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,30 @@ func (c *Client) HTTPClient() *http.Client {
return c.httpClient
}

// normalizeBaseURL accepts either a bare host[:port] or a full http(s) URL and
// returns (baseURL, host). A bare host is assumed https.
func normalizeBaseURL(s string) (baseURL, host string) {
s = strings.TrimRight(s, "/")
if h, ok := strings.CutPrefix(s, "http://"); ok {
return s, h
}
if h, ok := strings.CutPrefix(s, "https://"); ok {
return s, h
}
return "https://" + s, s
}
Comment on lines +134 to +145

// RegisterDomain detects the forge type for a domain and registers the
// appropriate Forge using the provided builder functions.
// appropriate Forge using the provided builder functions. The domain may
// include an http:// or https:// prefix; without one, https is assumed.
// The bare host[:port] is used as the registry key.
func (c *Client) RegisterDomain(ctx context.Context, domain, token string, builders ForgeBuilders) error {
ft, err := DetectForgeType(ctx, domain, c.httpClient)
baseURL, domain := normalizeBaseURL(domain)
ft, err := DetectForgeType(ctx, baseURL, c.httpClient)
if err != nil {
return fmt.Errorf("detecting forge type for %s: %w", domain, err)
}
c.tokens[domain] = token
baseURL := "https://" + domain
switch ft {
case GitHub:
c.forges[domain] = builders.GitHub(baseURL, token, c.httpClient)
Expand Down
46 changes: 46 additions & 0 deletions forges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,52 @@ func TestDetectForgeTypeUsesProvidedClient(t *testing.T) {
}
}

func TestDetectForgeTypeAcceptsHTTPURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Forgejo-Version", "7.0.0")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

// srv.URL is http://127.0.0.1:PORT — passing it directly must not be
// rewritten to https.
ft, err := DetectForgeType(context.Background(), srv.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ft != Forgejo {
t.Errorf("want Forgejo, got %s", ft)
}
}

func TestRegisterDomainAcceptsHTTPURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Forgejo-Version", "7.0.0")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

var gotBase string
c := NewClient()
err := c.RegisterDomain(context.Background(), srv.URL, "tok", ForgeBuilders{
Gitea: func(baseURL, token string, hc *http.Client) Forge {
gotBase = baseURL
return nil
},
})
if err != nil {
t.Fatalf("RegisterDomain: %v", err)
}
if gotBase != srv.URL {
t.Errorf("builder got base %q, want %q", gotBase, srv.URL)
}
// Registry key must be the bare host, not the full URL.
host := strings.TrimPrefix(srv.URL, "http://")
if _, err := c.ForgeFor(host); err != nil {
t.Errorf("ForgeFor(%q) after RegisterDomain(%q): %v", host, srv.URL, err)
}
}

func TestDetectForgeTypeHeaders(t *testing.T) {
tests := []struct {
header string
Expand Down
8 changes: 7 additions & 1 deletion internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func authLoginCmd() *cobra.Command {
token string
tokenCmd string
forgeType string
scheme string
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -69,7 +70,11 @@ func authLoginCmd() *cobra.Command {
}
}

if err := config.SetDomain(domain, token, tokenCmd, forgeType); err != nil {
if scheme != "" && scheme != "http" && scheme != "https" {
return fmt.Errorf("invalid --scheme %q: must be http or https", scheme)
}
Comment on lines +73 to +75

if err := config.SetDomain(domain, token, tokenCmd, forgeType, scheme); err != nil {
return fmt.Errorf("saving config: %w", err)
}

Expand All @@ -86,6 +91,7 @@ func authLoginCmd() *cobra.Command {
cmd.Flags().StringVar(&token, "token", "", "API token")
cmd.Flags().StringVar(&tokenCmd, "token-cmd", "", "Shell command whose stdout is used as the token")
cmd.Flags().StringVar(&forgeType, "type", "", "Forge type: github, gitlab, gitea, forgejo, bitbucket, gerrit, tangled")
cmd.Flags().StringVar(&scheme, "scheme", "", "API scheme: http or https (default https). Use http for plain-HTTP self-hosted instances.")
cmd.MarkFlagsMutuallyExclusive("token", "token-cmd")
return cmd
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func Execute() error {
func init() {
rootCmd.PersistentFlags().StringVarP(&flagRepo, "repo", "R", "", "Select a repository (OWNER/REPO or HOST/OWNER/REPO)")
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); overrides FORGE_HOST and remote detection")
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)")
}
Expand Down
24 changes: 23 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type DefaultSection struct {

type DomainSection struct {
Type string // github, gitlab, gitea, forgejo, bitbucket, gerrit, tangled
Scheme string // http or https; API base URL scheme (empty = https)
Token string // resolved token value; only from user config, never .forge
TokenExec string // non-empty when token is retrieved via a shell command (from "token-cmd" config key)
SSHHost string // alternate host for git-over-ssh; the section name remains the API host
Expand Down Expand Up @@ -94,6 +95,17 @@ func GitProtocolFor(domain string) string {
return "https"
}

func parseScheme(v string) (string, error) {
switch strings.ToLower(v) {
case "http":
return "http", nil
case "https":
return "https", nil
default:
return "", fmt.Errorf("invalid scheme %q: must be \"http\" or \"https\"", v)
}
}

func parseGitProtocol(v string) (string, error) {
switch strings.ToLower(v) {
case "ssh":
Expand Down Expand Up @@ -205,6 +217,13 @@ func loadFile(cfg *Config, path string, allowTokens bool) error {
if v, ok := kv["type"]; ok {
ds.Type = v
}
if v, ok := kv["scheme"]; ok {
s, err := parseScheme(v)
if err != nil {
return fmt.Errorf("%s: [%s] %w", path, name, err)
}
ds.Scheme = s
}
if v, ok := kv["git_protocol"]; ok {
p, err := parseGitProtocol(v)
if err != nil {
Expand Down Expand Up @@ -317,7 +336,7 @@ func findProjectConfig(dir string) string {
// SetDomain updates or adds a domain section in the user config file.
// Creates the config directory if needed. Sets file permissions to 0600
// since the file may contain tokens.
func SetDomain(domain, token, tokenCmd, forgeType string) error {
func SetDomain(domain, token, tokenCmd, forgeType, scheme string) error {
path := UserConfigPath()
if path == "" {
return fmt.Errorf("cannot determine config path")
Expand Down Expand Up @@ -351,6 +370,9 @@ func SetDomain(domain, token, tokenCmd, forgeType string) error {
if forgeType != "" {
sections[domain]["type"] = forgeType
}
if scheme != "" {
sections[domain]["scheme"] = scheme
}
Comment on lines +373 to +375

return writeINI(path, sections)
}
Expand Down
49 changes: 45 additions & 4 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ func TestSetDomain(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)

err := SetDomain("gitea.example.com", "tok123", "", "gitea")
err := SetDomain("gitea.example.com", "tok123", "", "gitea", "")
if err != nil {
t.Fatalf("SetDomain: %v", err)
}
Expand Down Expand Up @@ -406,7 +406,7 @@ func TestSetDomainTightensExistingPermissions(t *testing.T) {
t.Fatal(err)
}

if err := SetDomain("github.com", "ghp_secret", "", ""); err != nil {
if err := SetDomain("github.com", "ghp_secret", "", "", ""); err != nil {
t.Fatalf("SetDomain: %v", err)
}

Expand Down Expand Up @@ -434,7 +434,7 @@ type = gitlab
`), 0600)

// Add a new domain; existing entries should survive.
err := SetDomain("codeberg.org", "tok_new", "", "gitea")
err := SetDomain("codeberg.org", "tok_new", "", "gitea", "")
if err != nil {
t.Fatalf("SetDomain: %v", err)
}
Expand Down Expand Up @@ -468,7 +468,7 @@ token = old_token
`), 0600)

// Update
err := SetDomain("github.com", "new_token", "", "")
err := SetDomain("github.com", "new_token", "", "", "")
if err != nil {
t.Fatalf("SetDomain: %v", err)
}
Expand All @@ -483,6 +483,47 @@ token = old_token
}
}

func TestSetDomainScheme(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)

if err := SetDomain("forgejo.local:3000", "tok", "", "forgejo", "http"); err != nil {
t.Fatalf("SetDomain: %v", err)
}

data, _ := os.ReadFile(filepath.Join(dir, "forge", "config"))
content := string(data)
if !strings.Contains(content, "scheme = http") {
t.Errorf("expected scheme = http in config, got:\n%s", content)
}

ResetCache()
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if got := cfg.Domains["forgejo.local:3000"].Scheme; got != "http" {
t.Errorf("Scheme = %q, want http", got)
}
}

func TestLoadFileInvalidScheme(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config")
_ = os.WriteFile(path, []byte(`[forgejo.local]
scheme = ftp
`), 0600)

cfg := &Config{Domains: make(map[string]DomainSection)}
err := loadFile(cfg, path, true)
if err == nil {
t.Fatal("expected error for invalid scheme")
}
if !strings.Contains(err.Error(), "invalid scheme") {
t.Errorf("expected error about invalid scheme, got: %v", err)
}
}

func TestLoadFileTokenCommand(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config")
Expand Down
Loading