fj/internal/git/git.go
sid 113505de95 feat: v0.3.0d — add PR checks, iostreams, aliases, and broad enhancements
Add PR checks command, iostreams/text packages for colored table output,
top-level run/workflow aliases matching gh CLI structure. Enhance actions,
issues, PRs, releases, repos, labels, milestones, and wiki commands with
improved flags, JSON output, and error handling.
2026-03-23 12:42:24 -06:00

188 lines
5 KiB
Go

package git
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// DetectRepo attempts to detect the repository owner/name from the current git directory.
// It reads .git/config and parses the origin remote URL.
func DetectRepo() (owner, name 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 owner/name from URL
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
func findGitConfig() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get current directory: %w", err)
}
dir := cwd
for {
gitConfigPath := filepath.Join(dir, ".git", "config")
if _, err := os.Stat(gitConfigPath); err == nil {
return gitConfigPath, nil
}
parent := filepath.Dir(dir)
if parent == dir {
// Reached root directory
return "", fmt.Errorf("not in a git repository (or any parent up to mount point)")
}
dir = parent
}
}
// parseGitConfig reads .git/config and extracts the origin remote URL
func parseGitConfig(configPath string) (string, error) {
file, err := os.Open(configPath)
if err != nil {
return "", fmt.Errorf("failed to open git config: %w", err)
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
inOriginSection := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Check if we're entering the [remote "origin"] section
if strings.HasPrefix(line, "[remote") && strings.Contains(line, "origin") {
inOriginSection = true
continue
}
// Check if we're leaving the section
if inOriginSection && strings.HasPrefix(line, "[") {
inOriginSection = false
continue
}
// Extract URL from the origin section
if inOriginSection && strings.HasPrefix(line, "url") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1]), nil
}
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("error reading git config: %w", err)
}
return "", fmt.Errorf("no origin remote found in git config")
}
// GetCurrentBranch returns the name of the currently checked-out branch.
func GetCurrentBranch() (string, error) {
gitDir, err := findGitDir()
if err != nil {
return "", err
}
headPath := filepath.Join(gitDir, "HEAD")
data, err := os.ReadFile(headPath)
if err != nil {
return "", fmt.Errorf("failed to read .git/HEAD: %w", err)
}
headStr := strings.TrimSpace(string(data))
if strings.HasPrefix(headStr, "ref: refs/heads/") {
return strings.TrimPrefix(headStr, "ref: refs/heads/"), nil
}
return "", fmt.Errorf("HEAD is not on a branch (detached HEAD)")
}
// findGitDir searches for the .git directory starting from the current directory
func findGitDir() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get current directory: %w", err)
}
dir := cwd
for {
gitDir := filepath.Join(dir, ".git")
if info, err := os.Stat(gitDir); err == nil && info.IsDir() {
return gitDir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("not in a git repository")
}
dir = parent
}
}
// 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, 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) == 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) == 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) == 4 {
return matches[2], matches[3], matches[1], nil
}
return "", "", "", fmt.Errorf("unable to parse repository from URL: %s", url)
}