feat: auto detect hostname

This commit is contained in:
Romain Bertrand 2026-01-05 12:47:28 +01:00
parent 2c27823e18
commit c0baf4fa3b
13 changed files with 300 additions and 125 deletions

View file

@ -87,12 +87,12 @@ fgj pr view 123 # Automatically uses current repo
fgj pr list -R owner/repo fgj pr list -R owner/repo
``` ```
The tool reads `.git/config` to find the origin remote and extract the owner/repo information. If you're not in a git repository, you'll need to use the `-R` flag. The tool reads `.git/config` to find the origin remote and extract both the owner/repo information and the Forgejo instance hostname. If you're not in a git repository, you'll need to use the `-R` flag.
### Pull Requests ### Pull Requests
```bash ```bash
# List pull requests (auto-detects repo from git) # List pull requests (auto-detects repo and hostname from git)
fgj pr list fgj pr list
# Or specify explicitly # Or specify explicitly
@ -114,7 +114,7 @@ fgj pr merge 123 --merge-method squash
### Issues ### Issues
```bash ```bash
# List issues (auto-detects repo from git) # List issues (auto-detects repo and hostname from git)
fgj issue list fgj issue list
# Or specify explicitly # Or specify explicitly
@ -237,14 +237,22 @@ hosts:
### Environment Variables ### Environment Variables
- `FGJ_HOST`: Override the default Forgejo instance - `FGJ_HOST`: Override the default Forgejo instance (auto-detected from git remote if not set)
- `FGJ_TOKEN`: Provide authentication token - `FGJ_TOKEN`: Provide authentication token
Hostname is resolved in this priority order:
1. Command-specific flags (e.g., `--hostname`)
2. `FGJ_HOST` environment variable
3. Auto-detected from git remote URL
4. Default to `codeberg.org`
### Command-line Flags ### Command-line Flags
- `--hostname`: Specify Forgejo instance for a command - `--hostname`: Specify Forgejo instance for a command (overrides auto-detection and environment variables)
- `--config`: Use a custom config file - `--config`: Use a custom config file
When working in a git repository, `fgj` automatically detects the Forgejo instance from your origin remote URL, so you typically don't need to specify `--hostname` unless working with multiple instances.
## Use with AI Coding Agents ## Use with AI Coding Agents
`fgj` is designed to work seamlessly with AI coding agents like Claude Code. Common patterns: `fgj` is designed to work seamlessly with AI coding agents like Claude Code. Common patterns:

View file

@ -219,7 +219,7 @@ func runRunList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return fmt.Errorf("failed to create client: %w", err) return fmt.Errorf("failed to create client: %w", err)
} }
@ -277,7 +277,7 @@ func runRunView(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return fmt.Errorf("failed to create client: %w", err) return fmt.Errorf("failed to create client: %w", err)
} }
@ -507,7 +507,7 @@ func runActionsSecretList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return fmt.Errorf("failed to create client: %w", err) return fmt.Errorf("failed to create client: %w", err)
} }
@ -550,7 +550,7 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return fmt.Errorf("failed to create client: %w", err) return fmt.Errorf("failed to create client: %w", err)
} }
@ -591,7 +591,7 @@ func runActionsSecretDelete(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return fmt.Errorf("failed to create client: %w", err) return fmt.Errorf("failed to create client: %w", err)
} }
@ -626,7 +626,7 @@ func runActionsVariableGet(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return fmt.Errorf("failed to create client: %w", err) return fmt.Errorf("failed to create client: %w", err)
} }
@ -654,7 +654,7 @@ func runActionsVariableCreate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return fmt.Errorf("failed to create client: %w", err) return fmt.Errorf("failed to create client: %w", err)
} }
@ -683,7 +683,7 @@ func runActionsVariableUpdate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return fmt.Errorf("failed to create client: %w", err) return fmt.Errorf("failed to create client: %w", err)
} }
@ -712,7 +712,7 @@ func runActionsVariableDelete(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return fmt.Errorf("failed to create client: %w", err) return fmt.Errorf("failed to create client: %w", err)
} }

View file

@ -108,7 +108,7 @@ func runIssueList(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -166,7 +166,7 @@ func runIssueView(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -220,7 +220,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -261,7 +261,7 @@ func runIssueComment(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -296,7 +296,7 @@ func runIssueClose(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -339,7 +339,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }

View file

@ -11,7 +11,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/api"
"codeberg.org/romaintb/fgj/internal/config" "codeberg.org/romaintb/fgj/internal/config"
"codeberg.org/romaintb/fgj/internal/git"
) )
var prCmd = &cobra.Command{ var prCmd = &cobra.Command{
@ -88,7 +87,7 @@ func runPRList(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -144,7 +143,7 @@ func runPRView(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -198,7 +197,7 @@ func runPRCreate(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -252,7 +251,7 @@ func runPRMerge(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -281,21 +280,3 @@ func runPRMerge(cmd *cobra.Command, args []string) error {
return nil return nil
} }
func parseRepo(repo string) (string, string, error) {
// If repo flag is provided, use it
if repo != "" {
parts := strings.Split(repo, "/")
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid repository format: %s (expected: owner/name)", repo)
}
return parts[0], parts[1], nil
}
// Try to auto-detect from git
owner, name, err := git.DetectRepo()
if err != nil {
return "", "", fmt.Errorf("repository flag is required (use -R owner/name) or run from a git repository: %w", err)
}
return owner, name, nil
}

View file

@ -111,7 +111,7 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -176,7 +176,7 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -258,7 +258,7 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -309,7 +309,7 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -341,7 +341,7 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }

View file

@ -76,7 +76,7 @@ func runRepoView(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -108,7 +108,7 @@ func runRepoList(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -160,7 +160,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }
@ -221,7 +221,7 @@ func runRepoFork(cmd *cobra.Command, args []string) error {
return err return err
} }
client, err := api.NewClientFromConfig(cfg, "") client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil { if err != nil {
return err return err
} }

View file

@ -3,9 +3,11 @@ package cmd
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"codeberg.org/romaintb/fgj/internal/git"
) )
var cfgFile string var cfgFile string
@ -53,3 +55,34 @@ func initConfig() {
_ = viper.ReadInConfig() _ = viper.ReadInConfig()
} }
// parseRepo parses the repository string in the format "owner/name".
// If not provided, it attempts to auto-detect from the git repository.
func parseRepo(repo string) (string, string, error) {
// If repo flag is provided, use it
if repo != "" {
parts := strings.Split(repo, "/")
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid repository format: %s (expected: owner/name)", repo)
}
return parts[0], parts[1], nil
}
// Try to auto-detect from git
owner, name, err := git.DetectRepo()
if err != nil {
return "", "", fmt.Errorf("repository flag is required (use -R owner/name) or run from a git repository: %w", err)
}
return owner, name, nil
}
// getDetectedHost attempts to auto-detect the Forgejo instance hostname.
// Returns empty string if detection fails, which will fall back to other methods.
func getDetectedHost() string {
host, err := git.DetectHost()
if err != nil {
return ""
}
return host
}

View file

@ -33,8 +33,8 @@ func NewClient(hostname, token string) (*Client, error) {
}, nil }, nil
} }
func NewClientFromConfig(cfg *config.Config, hostname string) (*Client, error) { func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string) (*Client, error) {
host, err := cfg.GetHost(hostname) host, err := cfg.GetHost(hostname, detectedHost)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -21,7 +21,7 @@ func TestNewClientFromConfig_MissingHost(t *testing.T) {
Hosts: map[string]config.HostConfig{}, Hosts: map[string]config.HostConfig{},
} }
_, err := NewClientFromConfig(cfg, "nonexistent.org") _, err := NewClientFromConfig(cfg, "nonexistent.org", "")
if err == nil { if err == nil {
t.Error("Expected error for nonexistent host") t.Error("Expected error for nonexistent host")
} }

View file

@ -87,7 +87,14 @@ func (c *Config) SaveToPath(path string) error {
return os.WriteFile(path, data, 0600) return os.WriteFile(path, data, 0600)
} }
func (c *Config) GetHost(hostname string) (HostConfig, error) { // GetHost resolves the hostname to use for API client creation.
// Priority order:
// 1. Explicitly provided hostname parameter
// 2. CLI flag (--hostname)
// 3. Environment variable (FGJ_HOST)
// 4. Auto-detected hostname from git remote
// 5. Default to codeberg.org
func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, error) {
if hostname == "" { if hostname == "" {
hostname = viper.GetString("hostname") hostname = viper.GetString("hostname")
} }
@ -96,6 +103,10 @@ func (c *Config) GetHost(hostname string) (HostConfig, error) {
hostname = os.Getenv("FGJ_HOST") hostname = os.Getenv("FGJ_HOST")
} }
if hostname == "" {
hostname = detectedHost
}
if hostname == "" { if hostname == "" {
hostname = "codeberg.org" hostname = "codeberg.org"
} }

View file

@ -49,7 +49,7 @@ func TestConfig_GetHost(t *testing.T) {
}, },
} }
host, err := cfg.GetHost("codeberg.org") host, err := cfg.GetHost("codeberg.org", "")
if err != nil { if err != nil {
t.Fatalf("Unexpected error: %v", err) t.Fatalf("Unexpected error: %v", err)
} }
@ -58,7 +58,7 @@ func TestConfig_GetHost(t *testing.T) {
t.Errorf("Expected hostname 'codeberg.org', got '%s'", host.Hostname) t.Errorf("Expected hostname 'codeberg.org', got '%s'", host.Hostname)
} }
_, err = cfg.GetHost("nonexistent.org") _, err = cfg.GetHost("nonexistent.org", "")
if err == nil { if err == nil {
t.Error("Expected error for nonexistent host") t.Error("Expected error for nonexistent host")
} }
@ -261,7 +261,7 @@ func TestConfig_GetHost_EmptyString(t *testing.T) {
} }
// Empty hostname should default to codeberg.org // Empty hostname should default to codeberg.org
host, err := cfg.GetHost("") host, err := cfg.GetHost("", "")
if err != nil { if err != nil {
t.Fatalf("Unexpected error: %v", err) t.Fatalf("Unexpected error: %v", err)
} }
@ -282,7 +282,7 @@ func TestConfig_GetHost_WhitespaceString(t *testing.T) {
} }
// Whitespace-only hostname should default to codeberg.org // Whitespace-only hostname should default to codeberg.org
host, err := cfg.GetHost(" ") host, err := cfg.GetHost(" ", "")
if err == nil { if err == nil {
t.Logf("Got host: %+v (this may be expected behavior)", host) t.Logf("Got host: %+v (this may be expected behavior)", host)
} else { } else {
@ -301,7 +301,7 @@ func TestConfig_SetHost_EmptyToken(t *testing.T) {
cfg.SetHost("codeberg.org", hostConfig) cfg.SetHost("codeberg.org", hostConfig)
host, err := cfg.GetHost("codeberg.org") host, err := cfg.GetHost("codeberg.org", "")
if err != nil { if err != nil {
t.Fatalf("Unexpected error: %v", err) t.Fatalf("Unexpected error: %v", err)
} }
@ -331,7 +331,7 @@ func TestConfig_SetHost_OverwriteExisting(t *testing.T) {
cfg.SetHost("codeberg.org", newConfig) cfg.SetHost("codeberg.org", newConfig)
host, err := cfg.GetHost("codeberg.org") host, err := cfg.GetHost("codeberg.org", "")
if err != nil { if err != nil {
t.Fatalf("Unexpected error: %v", err) t.Fatalf("Unexpected error: %v", err)
} }
@ -374,7 +374,7 @@ func TestConfig_MultipleHosts(t *testing.T) {
// Verify each host can be retrieved correctly // Verify each host can be retrieved correctly
for _, h := range hosts { for _, h := range hosts {
host, err := cfg.GetHost(h.hostname) host, err := cfg.GetHost(h.hostname, "")
if err != nil { if err != nil {
t.Errorf("Failed to get host %s: %v", h.hostname, err) t.Errorf("Failed to get host %s: %v", h.hostname, err)
continue continue
@ -408,12 +408,12 @@ func TestConfig_GitProtocol(t *testing.T) {
}) })
// Verify protocols are stored correctly // Verify protocols are stored correctly
sshHost, _ := cfg.GetHost("test-ssh.org") sshHost, _ := cfg.GetHost("test-ssh.org", "")
if sshHost.GitProtocol != "ssh" { if sshHost.GitProtocol != "ssh" {
t.Errorf("Expected git_protocol 'ssh', got '%s'", sshHost.GitProtocol) t.Errorf("Expected git_protocol 'ssh', got '%s'", sshHost.GitProtocol)
} }
httpsHost, _ := cfg.GetHost("test-https.org") httpsHost, _ := cfg.GetHost("test-https.org", "")
if httpsHost.GitProtocol != "https" { if httpsHost.GitProtocol != "https" {
t.Errorf("Expected git_protocol 'https', got '%s'", httpsHost.GitProtocol) t.Errorf("Expected git_protocol 'https', got '%s'", httpsHost.GitProtocol)
} }

View file

@ -25,7 +25,28 @@ func DetectRepo() (owner, name string, err error) {
} }
// Extract owner/name from URL // Extract owner/name from URL
return parseRemoteURL(remoteURL) owner, name, _, err = parseRemoteURL(remoteURL)
return owner, name, err
}
// DetectHost attempts to detect the Forgejo instance hostname from the current git directory.
// It reads .git/config and parses the origin remote URL to extract the hostname.
func DetectHost() (hostname string, err error) {
// Find .git/config file
gitConfigPath, err := findGitConfig()
if err != nil {
return "", err
}
// Parse .git/config
remoteURL, err := parseGitConfig(gitConfigPath)
if err != nil {
return "", err
}
// Extract hostname from URL
_, _, hostname, err = parseRemoteURL(remoteURL)
return hostname, err
} }
// findGitConfig searches for .git/config starting from the current directory // findGitConfig searches for .git/config starting from the current directory
@ -93,33 +114,33 @@ func parseGitConfig(configPath string) (string, error) {
return "", fmt.Errorf("no origin remote found in git config") return "", fmt.Errorf("no origin remote found in git config")
} }
// parseRemoteURL extracts owner/name from various git URL formats: // parseRemoteURL extracts owner/name/hostname from various git URL formats:
// - https://codeberg.org/owner/name.git // - https://codeberg.org/owner/name.git
// - git@codeberg.org:owner/name.git // - git@codeberg.org:owner/name.git
// - ssh://git@codeberg.org/owner/name.git // - ssh://git@codeberg.org/owner/name.git
func parseRemoteURL(url string) (owner, name string, err error) { func parseRemoteURL(url string) (owner, name, hostname string, err error) {
url = strings.TrimSpace(url) url = strings.TrimSpace(url)
// Remove .git suffix // Remove .git suffix
url = strings.TrimSuffix(url, ".git") url = strings.TrimSuffix(url, ".git")
// Pattern for HTTPS URLs: https://host/owner/name // Pattern for HTTPS URLs: https://host/owner/name
httpsRegex := regexp.MustCompile(`https?://[^/]+/([^/]+)/([^/]+)`) httpsRegex := regexp.MustCompile(`https?://([^/]+)/([^/]+)/([^/]+)`)
if matches := httpsRegex.FindStringSubmatch(url); len(matches) == 3 { if matches := httpsRegex.FindStringSubmatch(url); len(matches) == 4 {
return matches[1], matches[2], nil return matches[2], matches[3], matches[1], nil
} }
// Pattern for SSH URLs: git@host:owner/name // Pattern for SSH URLs: git@host:owner/name
sshRegex := regexp.MustCompile(`git@[^:]+:([^/]+)/(.+)`) sshRegex := regexp.MustCompile(`git@([^:]+):([^/]+)/(.+)`)
if matches := sshRegex.FindStringSubmatch(url); len(matches) == 3 { if matches := sshRegex.FindStringSubmatch(url); len(matches) == 4 {
return matches[1], matches[2], nil return matches[2], matches[3], matches[1], nil
} }
// Pattern for SSH URLs with protocol: ssh://git@host/owner/name // Pattern for SSH URLs with protocol: ssh://git@host/owner/name
sshProtocolRegex := regexp.MustCompile(`ssh://git@[^/]+/([^/]+)/(.+)`) sshProtocolRegex := regexp.MustCompile(`ssh://(?:git@)?([^/]+)/([^/]+)/(.+)`)
if matches := sshProtocolRegex.FindStringSubmatch(url); len(matches) == 3 { if matches := sshProtocolRegex.FindStringSubmatch(url); len(matches) == 4 {
return matches[1], matches[2], nil return matches[2], matches[3], matches[1], nil
} }
return "", "", fmt.Errorf("unable to parse repository from URL: %s", url) return "", "", "", fmt.Errorf("unable to parse repository from URL: %s", url)
} }

View file

@ -1,6 +1,10 @@
package git package git
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestParseRemoteURL(t *testing.T) { func TestParseRemoteURL(t *testing.T) {
tests := []struct { tests := []struct {
@ -8,6 +12,7 @@ func TestParseRemoteURL(t *testing.T) {
url string url string
wantOwner string wantOwner string
wantName string wantName string
wantHost string
wantErr bool wantErr bool
}{ }{
{ {
@ -15,6 +20,7 @@ func TestParseRemoteURL(t *testing.T) {
url: "https://codeberg.org/romaintb/fgj.git", url: "https://codeberg.org/romaintb/fgj.git",
wantOwner: "romaintb", wantOwner: "romaintb",
wantName: "fgj", wantName: "fgj",
wantHost: "codeberg.org",
wantErr: false, wantErr: false,
}, },
{ {
@ -22,6 +28,7 @@ func TestParseRemoteURL(t *testing.T) {
url: "https://codeberg.org/romaintb/fgj", url: "https://codeberg.org/romaintb/fgj",
wantOwner: "romaintb", wantOwner: "romaintb",
wantName: "fgj", wantName: "fgj",
wantHost: "codeberg.org",
wantErr: false, wantErr: false,
}, },
{ {
@ -29,6 +36,7 @@ func TestParseRemoteURL(t *testing.T) {
url: "git@codeberg.org:romaintb/fgj.git", url: "git@codeberg.org:romaintb/fgj.git",
wantOwner: "romaintb", wantOwner: "romaintb",
wantName: "fgj", wantName: "fgj",
wantHost: "codeberg.org",
wantErr: false, wantErr: false,
}, },
{ {
@ -36,6 +44,7 @@ func TestParseRemoteURL(t *testing.T) {
url: "git@codeberg.org:romaintb/fgj", url: "git@codeberg.org:romaintb/fgj",
wantOwner: "romaintb", wantOwner: "romaintb",
wantName: "fgj", wantName: "fgj",
wantHost: "codeberg.org",
wantErr: false, wantErr: false,
}, },
{ {
@ -43,6 +52,7 @@ func TestParseRemoteURL(t *testing.T) {
url: "ssh://git@codeberg.org/romaintb/fgj.git", url: "ssh://git@codeberg.org/romaintb/fgj.git",
wantOwner: "romaintb", wantOwner: "romaintb",
wantName: "fgj", wantName: "fgj",
wantHost: "codeberg.org",
wantErr: false, wantErr: false,
}, },
{ {
@ -50,6 +60,7 @@ func TestParseRemoteURL(t *testing.T) {
url: "https://github.com/user/repo.git", url: "https://github.com/user/repo.git",
wantOwner: "user", wantOwner: "user",
wantName: "repo", wantName: "repo",
wantHost: "github.com",
wantErr: false, wantErr: false,
}, },
{ {
@ -67,6 +78,7 @@ func TestParseRemoteURL(t *testing.T) {
url: " https://codeberg.org/owner/repo.git ", url: " https://codeberg.org/owner/repo.git ",
wantOwner: "owner", wantOwner: "owner",
wantName: "repo", wantName: "repo",
wantHost: "codeberg.org",
wantErr: false, wantErr: false,
}, },
{ {
@ -74,16 +86,15 @@ func TestParseRemoteURL(t *testing.T) {
url: "https://git.example.com:443/owner/repo.git", url: "https://git.example.com:443/owner/repo.git",
wantOwner: "owner", wantOwner: "owner",
wantName: "repo", wantName: "repo",
wantHost: "git.example.com:443",
wantErr: false, wantErr: false,
}, },
{ {
name: "SSH URL with port parses incorrectly", name: "SSH URL with port parses incorrectly",
url: "ssh://git@git.example.com:22/owner/repo.git", url: "ssh://git@git.example.com:22/owner/repo.git",
// Note: This currently parses as owner="22" name="owner/repo"
// which is incorrect but the regex matches. We document this
// limitation rather than make the test fail.
wantOwner: "22", wantOwner: "22",
wantName: "owner/repo", wantName: "owner/repo",
wantHost: "git.example.com",
wantErr: false, wantErr: false,
}, },
{ {
@ -91,6 +102,7 @@ func TestParseRemoteURL(t *testing.T) {
url: "http://codeberg.org/owner/repo", url: "http://codeberg.org/owner/repo",
wantOwner: "owner", wantOwner: "owner",
wantName: "repo", wantName: "repo",
wantHost: "codeberg.org",
wantErr: false, wantErr: false,
}, },
{ {
@ -98,6 +110,7 @@ func TestParseRemoteURL(t *testing.T) {
url: "https://codeberg.org/owner/my-cool-repo.git", url: "https://codeberg.org/owner/my-cool-repo.git",
wantOwner: "owner", wantOwner: "owner",
wantName: "my-cool-repo", wantName: "my-cool-repo",
wantHost: "codeberg.org",
wantErr: false, wantErr: false,
}, },
{ {
@ -105,6 +118,7 @@ func TestParseRemoteURL(t *testing.T) {
url: "https://codeberg.org/owner/my.repo.name.git", url: "https://codeberg.org/owner/my.repo.name.git",
wantOwner: "owner", wantOwner: "owner",
wantName: "my.repo.name", wantName: "my.repo.name",
wantHost: "codeberg.org",
wantErr: false, wantErr: false,
}, },
{ {
@ -112,6 +126,7 @@ func TestParseRemoteURL(t *testing.T) {
url: "https://codeberg.org/owner.name/repo.git", url: "https://codeberg.org/owner.name/repo.git",
wantOwner: "owner.name", wantOwner: "owner.name",
wantName: "repo", wantName: "repo",
wantHost: "codeberg.org",
wantErr: false, wantErr: false,
}, },
{ {
@ -128,7 +143,7 @@ func TestParseRemoteURL(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
owner, name, err := parseRemoteURL(tt.url) owner, name, host, err := parseRemoteURL(tt.url)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("parseRemoteURL() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("parseRemoteURL() error = %v, wantErr %v", err, tt.wantErr)
return return
@ -140,6 +155,112 @@ func TestParseRemoteURL(t *testing.T) {
if name != tt.wantName { if name != tt.wantName {
t.Errorf("parseRemoteURL() name = %v, want %v", name, tt.wantName) t.Errorf("parseRemoteURL() name = %v, want %v", name, tt.wantName)
} }
if host != tt.wantHost {
t.Errorf("parseRemoteURL() host = %v, want %v", host, tt.wantHost)
}
}
})
}
}
func TestDetectHost(t *testing.T) {
tests := []struct {
name string
gitConfig string
wantHost string
wantErr bool
}{
{
name: "HTTPS URL",
gitConfig: `[core]
repositoryformatversion = 0
[remote "origin"]
url = https://codeberg.org/owner/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
`,
wantHost: "codeberg.org",
wantErr: false,
},
{
name: "SSH URL",
gitConfig: `[core]
repositoryformatversion = 0
[remote "origin"]
url = git@codeberg.org:owner/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
`,
wantHost: "codeberg.org",
wantErr: false,
},
{
name: "HTTPS URL with port",
gitConfig: `[core]
repositoryformatversion = 0
[remote "origin"]
url = https://git.example.com:443/owner/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
`,
wantHost: "git.example.com:443",
wantErr: false,
},
{
name: "SSH protocol URL",
gitConfig: `[core]
repositoryformatversion = 0
[remote "origin"]
url = ssh://git@codeberg.org/owner/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
`,
wantHost: "codeberg.org",
wantErr: false,
},
{
name: "No origin remote",
gitConfig: `[core]
repositoryformatversion = 0
[remote "upstream"]
url = https://codeberg.org/owner/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
`,
wantHost: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
gitDir := filepath.Join(tmpDir, ".git")
if err := os.MkdirAll(gitDir, 0755); err != nil {
t.Fatalf("Failed to create .git directory: %v", err)
}
configPath := filepath.Join(gitDir, "config")
if err := os.WriteFile(configPath, []byte(tt.gitConfig), 0644); err != nil {
t.Fatalf("Failed to write git config: %v", err)
}
oldWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current directory: %v", err)
}
defer func() {
if err := os.Chdir(oldWd); err != nil {
t.Logf("Failed to change directory back: %v", err)
}
}()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}
host, err := DetectHost()
if (err != nil) != tt.wantErr {
t.Errorf("DetectHost() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && host != tt.wantHost {
t.Errorf("DetectHost() host = %v, want %v", host, tt.wantHost)
} }
}) })
} }