diff --git a/internal/releases/gitea/gitea.go b/internal/releases/gitea/gitea.go new file mode 100644 index 0000000..a2f691b --- /dev/null +++ b/internal/releases/gitea/gitea.go @@ -0,0 +1,24 @@ +// Package gitea fetches releases from a Gitea (or Forgejo) instance. +// +// Gitea's release API is GitHub-compatible but lives under /api/v1: +// +// GET {baseurl}/api/v1/repos/{owner}/{repo}/releases +// +// This package appends the /api/v1 prefix and delegates to [githubish]. +package gitea + +import ( + "context" + "iter" + "net/http" + "strings" + + "github.com/webinstall/webi-installers/internal/releases/githubish" +) + +// Fetch retrieves releases from a Gitea instance. +// The baseURL should be the Gitea root (e.g. "https://git.rootprojects.org"), +// not the API path — /api/v1 is appended automatically. +func Fetch(ctx context.Context, client *http.Client, baseURL, owner, repo string, auth *githubish.Auth) iter.Seq2[[]githubish.Release, error] { + return githubish.Fetch(ctx, client, strings.TrimRight(baseURL, "/")+"/api/v1", owner, repo, auth) +} diff --git a/internal/releases/gittag/gittag.go b/internal/releases/gittag/gittag.go new file mode 100644 index 0000000..7a9bf41 --- /dev/null +++ b/internal/releases/gittag/gittag.go @@ -0,0 +1,178 @@ +// Package gittag fetches release information from git tags in a bare repo. +// +// Some packages (vim plugins, shell scripts) are installed by cloning a git +// repo rather than downloading a binary. For these, each tag is a "release" +// and the download URL is the repo's git URL. +// +// This package clones (or fetches) a bare repo to a local cache directory, +// lists version-like tags, and returns them with their commit metadata. +// HEAD is also included as a potential release. +package gittag + +import ( + "context" + "fmt" + "iter" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "crypto/rand" + "encoding/hex" +) + +// Entry is one tag (or HEAD) from a git repo. +type Entry struct { + Version string // tag name or date-based version for HEAD + GitTag string // the ref that can be passed to `git clone --branch` + CommitHash string // abbreviated commit hash + Date string // ISO 8601 commit date (author date) +} + +// reVersionTag matches tags that look like versions: v1, v1.2, 1.0.0-rc, etc. +var reVersionTag = regexp.MustCompile(`^v?\d+(\.\d+)`) + +// Fetch clones or updates a bare repo, then yields its version-like tags +// and HEAD as entries. The repoDir is the parent directory where bare repos +// are cached. +// +// Yields one batch containing all tags plus HEAD. +func Fetch(ctx context.Context, gitURL, repoDir string) iter.Seq2[[]Entry, error] { + return func(yield func([]Entry, error) bool) { + repoName := filepath.Base(gitURL) + repoName = strings.TrimSuffix(repoName, ".git") + repoPath := filepath.Join(repoDir, repoName+".git") + + if err := ensureRepo(ctx, repoPath, gitURL); err != nil { + yield(nil, fmt.Errorf("gittag: %w", err)) + return + } + + tags, err := listVersionTags(ctx, repoPath) + if err != nil { + yield(nil, fmt.Errorf("gittag: %w", err)) + return + } + + var entries []Entry + for _, tag := range tags { + info, err := commitInfo(ctx, repoPath, tag) + if err != nil { + yield(nil, fmt.Errorf("gittag: commit info for %q: %w", tag, err)) + return + } + info.Version = tag + info.GitTag = tag + entries = append(entries, info) + } + + // HEAD as an additional entry + head, err := commitInfo(ctx, repoPath, "HEAD") + if err != nil { + yield(nil, fmt.Errorf("gittag: commit info for HEAD: %w", err)) + return + } + branch, err := headBranch(ctx, repoPath) + if err != nil { + yield(nil, fmt.Errorf("gittag: HEAD branch: %w", err)) + return + } + head.GitTag = branch + // Version for HEAD is set by the caller (date-based, etc.) + entries = append(entries, head) + + yield(entries, nil) + } +} + +// ensureRepo clones the repo if it doesn't exist, or fetches if it does. +func ensureRepo(ctx context.Context, repoPath, gitURL string) error { + if _, err := os.Stat(repoPath); err == nil { + // Exists — fetch updates. + cmd := exec.CommandContext(ctx, "git", "--git-dir="+repoPath, "fetch") + cmd.Stderr = os.Stderr + return cmd.Run() + } + + // Clone bare with tree filter (metadata only). + var b [8]byte + rand.Read(b[:]) + id := hex.EncodeToString(b[:]) + tmpPath := repoPath + "." + id + ".tmp" + + cmd := exec.CommandContext(ctx, "git", "clone", "--bare", "--filter=tree:0", gitURL, tmpPath) + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + os.RemoveAll(tmpPath) + return fmt.Errorf("clone %s: %w", gitURL, err) + } + + // Atomic swap — if repoPath appeared in a race, keep it and discard ours. + if err := os.Rename(tmpPath, repoPath); err != nil { + os.RemoveAll(tmpPath) + // If rename failed because repoPath now exists, that's fine. + if _, statErr := os.Stat(repoPath); statErr == nil { + return nil + } + return err + } + return nil +} + +// listVersionTags returns tags that look like version numbers, newest first. +func listVersionTags(ctx context.Context, repoPath string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "--git-dir="+repoPath, "tag") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git tag: %w", err) + } + + var tags []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + if reVersionTag.MatchString(line) { + tags = append(tags, line) + } + } + + // Reverse so newest tags come first (git tag outputs alphabetically). + for i, j := 0, len(tags)-1; i < j; i, j = i+1, j-1 { + tags[i], tags[j] = tags[j], tags[i] + } + return tags, nil +} + +// commitInfo returns the abbreviated hash and author date for a commitish. +func commitInfo(ctx context.Context, repoPath, commitish string) (Entry, error) { + cmd := exec.CommandContext(ctx, "git", "--git-dir="+repoPath, + "log", "-1", "--format=%h %ad", "--date=iso-strict", commitish) + out, err := cmd.Output() + if err != nil { + return Entry{}, fmt.Errorf("git log %s: %w", commitish, err) + } + + parts := strings.Fields(strings.TrimSpace(string(out))) + if len(parts) < 2 { + return Entry{}, fmt.Errorf("unexpected git log output: %q", out) + } + + return Entry{ + CommitHash: parts[0], + Date: parts[1], + }, nil +} + +// headBranch returns the symbolic ref for HEAD (e.g. "main", "master"). +func headBranch(ctx context.Context, repoPath string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "--git-dir="+repoPath, + "rev-parse", "--abbrev-ref", "HEAD") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git rev-parse HEAD: %w", err) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/releases/gittag/gittag_test.go b/internal/releases/gittag/gittag_test.go new file mode 100644 index 0000000..4a37881 --- /dev/null +++ b/internal/releases/gittag/gittag_test.go @@ -0,0 +1,56 @@ +package gittag_test + +import ( + "context" + "testing" + + "github.com/webinstall/webi-installers/internal/releases/gittag" +) + +func TestFetch(t *testing.T) { + if testing.Short() { + t.Skip("skipping network/git test in short mode") + } + + ctx := context.Background() + repoDir := t.TempDir() + + // vim-commentary has a small number of tags. + var entries []gittag.Entry + for batch, err := range gittag.Fetch(ctx, "https://github.com/tpope/vim-commentary.git", repoDir) { + if err != nil { + t.Fatalf("Fetch: %v", err) + } + entries = append(entries, batch...) + } + + if len(entries) < 2 { + t.Fatalf("got %d entries, expected at least 2 (tags + HEAD)", len(entries)) + } + + // Last entry should be HEAD (no Version set by the fetcher). + head := entries[len(entries)-1] + if head.CommitHash == "" { + t.Error("HEAD entry has empty CommitHash") + } + if head.Date == "" { + t.Error("HEAD entry has empty Date") + } + if head.GitTag == "" { + t.Error("HEAD entry has empty GitTag (branch name)") + } + + // At least one tag entry should have a version. + found := false + for _, e := range entries[:len(entries)-1] { + if e.Version != "" { + found = true + break + } + } + if !found { + t.Error("no tag entries have a Version set") + } + + t.Logf("fetched %d entries (last is HEAD on %q)", len(entries), head.GitTag) +}