diff --git a/README.md b/README.md index c23ed91..9108552 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,12 @@ fgj pr view 123 # Automatically uses current 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 ```bash -# List pull requests (auto-detects repo from git) +# List pull requests (auto-detects repo and hostname from git) fgj pr list # Or specify explicitly @@ -114,7 +114,7 @@ fgj pr merge 123 --merge-method squash ### Issues ```bash -# List issues (auto-detects repo from git) +# List issues (auto-detects repo and hostname from git) fgj issue list # Or specify explicitly @@ -237,14 +237,22 @@ hosts: ### 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 +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 -- `--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 +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 `fgj` is designed to work seamlessly with AI coding agents like Claude Code. Common patterns: diff --git a/cmd/actions.go b/cmd/actions.go index e9dbba0..bedb839 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -219,7 +219,7 @@ func runRunList(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { 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) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { 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) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { 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) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { 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) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { 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) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { 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) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { 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) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { 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) } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return fmt.Errorf("failed to create client: %w", err) } diff --git a/cmd/issue.go b/cmd/issue.go index f7dfd7c..4caf5bf 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -108,7 +108,7 @@ func runIssueList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -166,7 +166,7 @@ func runIssueView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -220,7 +220,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -261,7 +261,7 @@ func runIssueComment(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -296,7 +296,7 @@ func runIssueClose(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -339,7 +339,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } diff --git a/cmd/pr.go b/cmd/pr.go index df488d8..9c15401 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -11,7 +11,6 @@ import ( "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" - "codeberg.org/romaintb/fgj/internal/git" ) var prCmd = &cobra.Command{ @@ -88,7 +87,7 @@ func runPRList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -144,7 +143,7 @@ func runPRView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -198,7 +197,7 @@ func runPRCreate(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -252,7 +251,7 @@ func runPRMerge(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -281,21 +280,3 @@ func runPRMerge(cmd *cobra.Command, args []string) error { 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 -} diff --git a/cmd/release.go b/cmd/release.go index eef4bdc..8c04768 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -111,7 +111,7 @@ func runReleaseList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -176,7 +176,7 @@ func runReleaseView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -258,7 +258,7 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -309,7 +309,7 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -341,7 +341,7 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } diff --git a/cmd/repo.go b/cmd/repo.go index 41f6650..3264196 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -76,7 +76,7 @@ func runRepoView(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -108,7 +108,7 @@ func runRepoList(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -160,7 +160,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } @@ -221,7 +221,7 @@ func runRepoFork(cmd *cobra.Command, args []string) error { return err } - client, err := api.NewClientFromConfig(cfg, "") + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost()) if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index 34fef9c..89d3b7d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,9 +3,11 @@ package cmd import ( "fmt" "os" + "strings" "github.com/spf13/cobra" "github.com/spf13/viper" + "codeberg.org/romaintb/fgj/internal/git" ) var cfgFile string @@ -53,3 +55,34 @@ func initConfig() { _ = 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 +} diff --git a/internal/api/client.go b/internal/api/client.go index 42067c6..dd180b9 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -33,8 +33,8 @@ func NewClient(hostname, token string) (*Client, error) { }, nil } -func NewClientFromConfig(cfg *config.Config, hostname string) (*Client, error) { - host, err := cfg.GetHost(hostname) +func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string) (*Client, error) { + host, err := cfg.GetHost(hostname, detectedHost) if err != nil { return nil, err } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 0bbc3ca..3cb469b 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -21,7 +21,7 @@ func TestNewClientFromConfig_MissingHost(t *testing.T) { Hosts: map[string]config.HostConfig{}, } - _, err := NewClientFromConfig(cfg, "nonexistent.org") + _, err := NewClientFromConfig(cfg, "nonexistent.org", "") if err == nil { t.Error("Expected error for nonexistent host") } diff --git a/internal/config/config.go b/internal/config/config.go index 41c7409..9584efc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -87,7 +87,14 @@ func (c *Config) SaveToPath(path string) error { 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 == "" { hostname = viper.GetString("hostname") } @@ -96,6 +103,10 @@ func (c *Config) GetHost(hostname string) (HostConfig, error) { hostname = os.Getenv("FGJ_HOST") } + if hostname == "" { + hostname = detectedHost + } + if hostname == "" { hostname = "codeberg.org" } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0999170..6912963 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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 { 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) } - _, err = cfg.GetHost("nonexistent.org") + _, err = cfg.GetHost("nonexistent.org", "") if err == nil { 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 - host, err := cfg.GetHost("") + host, err := cfg.GetHost("", "") if err != nil { 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 - host, err := cfg.GetHost(" ") + host, err := cfg.GetHost(" ", "") if err == nil { t.Logf("Got host: %+v (this may be expected behavior)", host) } else { @@ -301,7 +301,7 @@ func TestConfig_SetHost_EmptyToken(t *testing.T) { cfg.SetHost("codeberg.org", hostConfig) - host, err := cfg.GetHost("codeberg.org") + host, err := cfg.GetHost("codeberg.org", "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -331,7 +331,7 @@ func TestConfig_SetHost_OverwriteExisting(t *testing.T) { cfg.SetHost("codeberg.org", newConfig) - host, err := cfg.GetHost("codeberg.org") + host, err := cfg.GetHost("codeberg.org", "") if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -374,7 +374,7 @@ func TestConfig_MultipleHosts(t *testing.T) { // Verify each host can be retrieved correctly for _, h := range hosts { - host, err := cfg.GetHost(h.hostname) + host, err := cfg.GetHost(h.hostname, "") if err != nil { t.Errorf("Failed to get host %s: %v", h.hostname, err) continue @@ -408,12 +408,12 @@ func TestConfig_GitProtocol(t *testing.T) { }) // Verify protocols are stored correctly - sshHost, _ := cfg.GetHost("test-ssh.org") + sshHost, _ := cfg.GetHost("test-ssh.org", "") if sshHost.GitProtocol != "ssh" { 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" { t.Errorf("Expected git_protocol 'https', got '%s'", httpsHost.GitProtocol) } diff --git a/internal/git/git.go b/internal/git/git.go index 1f0282b..425c764 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -25,7 +25,28 @@ func DetectRepo() (owner, name string, err error) { } // 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 @@ -93,33 +114,33 @@ func parseGitConfig(configPath string) (string, error) { 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 // - 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) // Remove .git suffix url = strings.TrimSuffix(url, ".git") // Pattern for HTTPS URLs: https://host/owner/name - httpsRegex := regexp.MustCompile(`https?://[^/]+/([^/]+)/([^/]+)`) - if matches := httpsRegex.FindStringSubmatch(url); len(matches) == 3 { - return matches[1], matches[2], nil + httpsRegex := regexp.MustCompile(`https?://([^/]+)/([^/]+)/([^/]+)`) + if matches := httpsRegex.FindStringSubmatch(url); len(matches) == 4 { + return matches[2], matches[3], matches[1], nil } // Pattern for SSH URLs: git@host:owner/name - sshRegex := regexp.MustCompile(`git@[^:]+:([^/]+)/(.+)`) - if matches := sshRegex.FindStringSubmatch(url); len(matches) == 3 { - return matches[1], matches[2], nil + sshRegex := regexp.MustCompile(`git@([^:]+):([^/]+)/(.+)`) + if matches := sshRegex.FindStringSubmatch(url); len(matches) == 4 { + return matches[2], matches[3], matches[1], nil } // Pattern for SSH URLs with protocol: ssh://git@host/owner/name - sshProtocolRegex := regexp.MustCompile(`ssh://git@[^/]+/([^/]+)/(.+)`) - if matches := sshProtocolRegex.FindStringSubmatch(url); len(matches) == 3 { - return matches[1], matches[2], nil + sshProtocolRegex := regexp.MustCompile(`ssh://(?:git@)?([^/]+)/([^/]+)/(.+)`) + if matches := sshProtocolRegex.FindStringSubmatch(url); len(matches) == 4 { + 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) } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index da7b90a..76d8764 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1,56 +1,67 @@ package git -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestParseRemoteURL(t *testing.T) { tests := []struct { - name string - url string + name string + url string wantOwner string wantName string - wantErr bool + wantHost string + wantErr bool }{ { - name: "HTTPS URL with .git", - url: "https://codeberg.org/romaintb/fgj.git", + name: "HTTPS URL with .git", + url: "https://codeberg.org/romaintb/fgj.git", wantOwner: "romaintb", wantName: "fgj", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "HTTPS URL without .git", - url: "https://codeberg.org/romaintb/fgj", + name: "HTTPS URL without .git", + url: "https://codeberg.org/romaintb/fgj", wantOwner: "romaintb", wantName: "fgj", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "SSH URL with .git", - url: "git@codeberg.org:romaintb/fgj.git", + name: "SSH URL with .git", + url: "git@codeberg.org:romaintb/fgj.git", wantOwner: "romaintb", wantName: "fgj", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "SSH URL without .git", - url: "git@codeberg.org:romaintb/fgj", + name: "SSH URL without .git", + url: "git@codeberg.org:romaintb/fgj", wantOwner: "romaintb", wantName: "fgj", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "SSH protocol URL", - url: "ssh://git@codeberg.org/romaintb/fgj.git", + name: "SSH protocol URL", + url: "ssh://git@codeberg.org/romaintb/fgj.git", wantOwner: "romaintb", wantName: "fgj", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "GitHub HTTPS URL", - url: "https://github.com/user/repo.git", + name: "GitHub HTTPS URL", + url: "https://github.com/user/repo.git", wantOwner: "user", wantName: "repo", - wantErr: false, + wantHost: "github.com", + wantErr: false, }, { name: "Invalid URL", @@ -63,56 +74,60 @@ func TestParseRemoteURL(t *testing.T) { wantErr: true, }, { - name: "URL with trailing whitespace", - url: " https://codeberg.org/owner/repo.git ", + name: "URL with trailing whitespace", + url: " https://codeberg.org/owner/repo.git ", wantOwner: "owner", wantName: "repo", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "URL with port number", - url: "https://git.example.com:443/owner/repo.git", + name: "URL with port number", + url: "https://git.example.com:443/owner/repo.git", wantOwner: "owner", wantName: "repo", - wantErr: false, + wantHost: "git.example.com:443", + wantErr: false, }, { - name: "SSH URL with port parses incorrectly", - 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. + name: "SSH URL with port parses incorrectly", + url: "ssh://git@git.example.com:22/owner/repo.git", wantOwner: "22", wantName: "owner/repo", - wantErr: false, + wantHost: "git.example.com", + wantErr: false, }, { - name: "HTTP URL (not HTTPS)", - url: "http://codeberg.org/owner/repo", + name: "HTTP URL (not HTTPS)", + url: "http://codeberg.org/owner/repo", wantOwner: "owner", wantName: "repo", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "Repo name with dashes", - url: "https://codeberg.org/owner/my-cool-repo.git", + name: "Repo name with dashes", + url: "https://codeberg.org/owner/my-cool-repo.git", wantOwner: "owner", wantName: "my-cool-repo", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "Repo name with dots", - url: "https://codeberg.org/owner/my.repo.name.git", + name: "Repo name with dots", + url: "https://codeberg.org/owner/my.repo.name.git", wantOwner: "owner", wantName: "my.repo.name", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { - name: "Owner with dots", - url: "https://codeberg.org/owner.name/repo.git", + name: "Owner with dots", + url: "https://codeberg.org/owner.name/repo.git", wantOwner: "owner.name", wantName: "repo", - wantErr: false, + wantHost: "codeberg.org", + wantErr: false, }, { name: "Missing owner/repo", @@ -128,7 +143,7 @@ func TestParseRemoteURL(t *testing.T) { for _, tt := range tests { 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 { t.Errorf("parseRemoteURL() error = %v, wantErr %v", err, tt.wantErr) return @@ -140,6 +155,112 @@ func TestParseRemoteURL(t *testing.T) { if 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) } }) }