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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# forge

Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, and Bitbucket Cloud through a single interface.
Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, Bitbucket Cloud, and Tangled through a single interface.

## CLI

Expand Down Expand Up @@ -46,7 +46,7 @@ forge auth login --domain gitea.example.com --token abc123 --type gitea

Check what's configured with `forge auth status`.

Tokens are resolved in this order: CLI flags, environment variables (`FORGE_TOKEN`, `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`/`GITEA_TOKEN`, `BITBUCKET_TOKEN`), then the config file at `~/.config/forge/config`. The target host is inferred from the current directory's git remote; use `--host` or `FORGE_HOST` to override it (for example `forge --host gitea.com repo list someone`).
Tokens are resolved in this order: CLI flags, environment variables (`FORGE_TOKEN`, `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`/`GITEA_TOKEN`, `BITBUCKET_TOKEN`, `TANGLED_TOKEN`), then the config file at `~/.config/forge/config`. The target host is inferred from the current directory's git remote; use `--host` or `FORGE_HOST` to override it (for example `forge --host gitea.com repo list someone`).

### Configuration

Expand Down Expand Up @@ -109,6 +109,7 @@ Self-hosted instances can be registered explicitly or detected automatically:
import (
"github.com/git-pkgs/forge/gitea"
"github.com/git-pkgs/forge/gitlab"
"github.com/git-pkgs/forge/tangled"
)

client := forges.NewClient(
Expand All @@ -121,6 +122,7 @@ err := client.RegisterDomain(ctx, "git.example.com", token, forges.ForgeBuilders
GitHub: github.NewWithBase,
GitLab: gitlab.New,
Gitea: gitea.New,
Tangled: tangled.New,
})
```

Expand Down
5 changes: 5 additions & 0 deletions detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ func detectFromAPI(ctx context.Context, client *http.Client, baseURL string) (Fo
return GitHub, nil
}

// Try Tangled knot/appview XRPC endpoint.
if ok, err := probeURL(ctx, client, baseURL+"/xrpc/sh.tangled.knot.version"); err == nil && ok {
return Tangled, nil
}

return Unknown, fmt.Errorf("could not detect forge type for %s", baseURL)
}

Expand Down
9 changes: 6 additions & 3 deletions forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ func (c *Client) RegisterDomain(ctx context.Context, domain, token string, build
c.forges[domain] = builders.GitLab(baseURL, token, c.httpClient)
case Gitea, Forgejo:
c.forges[domain] = builders.Gitea(baseURL, token, c.httpClient)
case Tangled:
c.forges[domain] = builders.Tangled(baseURL, token, c.httpClient)
default:
return fmt.Errorf("unsupported forge type %q for %s", ft, domain)
}
Expand All @@ -150,9 +152,10 @@ func (c *Client) RegisterDomain(ctx context.Context, domain, token string, build
// ForgeBuilders holds constructor functions for each forge type.
// Used by RegisterDomain to create the right forge after detection.
type ForgeBuilders struct {
GitHub func(baseURL, token string, hc *http.Client) Forge
GitLab func(baseURL, token string, hc *http.Client) Forge
Gitea func(baseURL, token string, hc *http.Client) Forge
GitHub func(baseURL, token string, hc *http.Client) Forge
GitLab func(baseURL, token string, hc *http.Client) Forge
Gitea func(baseURL, token string, hc *http.Client) Forge
Tangled func(baseURL, token string, hc *http.Client) Forge
}

func (c *Client) forgeFor(domain string) (Forge, error) {
Expand Down
30 changes: 30 additions & 0 deletions forges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,36 @@ func TestDetectForgeTypeGitHubAPI(t *testing.T) {
}
}

func TestDetectForgeTypeTangledAPI(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /api/v1/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("GET /api/v4/version", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("GET /api/v3/meta", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("GET /xrpc/sh.tangled.knot.version", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `{"version":"0.1.0"}`)
})

srv := httptest.NewServer(mux)
defer srv.Close()

ft, err := detectFromAPI(context.Background(), http.DefaultClient, srv.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ft != Tangled {
t.Errorf("want Tangled, got %s", ft)
}
}

func TestClientListRepositoriesRoutes(t *testing.T) {
mock := &mockForge{
repoService: &mockRepoService{
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func authLoginCmd() *cobra.Command {

cmd.Flags().StringVar(&domain, "domain", "", "Forge domain (e.g. github.com, gitea.example.com)")
cmd.Flags().StringVar(&token, "token", "", "API token")
cmd.Flags().StringVar(&forgeType, "type", "", "Forge type: github, gitlab, gitea, forgejo, bitbucket")
cmd.Flags().StringVar(&forgeType, "type", "", "Forge type: github, gitlab, gitea, forgejo, bitbucket, tangled")
return cmd
}

Expand All @@ -91,7 +91,7 @@ func authStatusCmd() *cobra.Command {
}

// Known domains to check for env var tokens
knownDomains := []string{"github.com", "gitlab.com", "codeberg.org", "bitbucket.org"}
knownDomains := []string{"github.com", "gitlab.com", "codeberg.org", "bitbucket.org", "tangled.org"}

// Collect all unique domains
domains := make(map[string]bool)
Expand Down
1 change: 1 addition & 0 deletions internal/cli/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ func TestDomainFromFlags(t *testing.T) {
{"gitea", "codeberg.org"},
{"forgejo", "codeberg.org"},
{"bitbucket", "bitbucket.org"},
{"tangled", "tangled.org"},
}

for _, tt := range tests {
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var (
var rootCmd = &cobra.Command{
Use: "forge",
Short: "Work with git forges from the command line",
Long: "Supports GitHub, GitLab, Gitea, Forgejo, and Bitbucket Cloud through a single interface.",
Long: "Supports GitHub, GitLab, Gitea, Forgejo, Bitbucket Cloud, and Tangled through a single interface.",
SilenceUsage: true,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if !cmd.Flags().Changed("output") {
Expand All @@ -45,7 +45,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")
rootCmd.PersistentFlags().StringVar(&flagForgeType, "forge-type", "", "Force forge type: github, gitlab, gitea, forgejo, bitbucket, tangled")
rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "Force forge host (e.g. gitea.com); 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
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ type DefaultSection struct {
}

type DomainSection struct {
Type string // github, gitlab, gitea, forgejo, bitbucket
Type string // github, gitlab, gitea, forgejo, bitbucket, tangled
Token string // only from user config, never .forge
SSHHost string // alternate host for git-over-ssh; the section name remains the API host
GitProtocol string // https or ssh; overrides default
Expand Down
17 changes: 14 additions & 3 deletions internal/resolve/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
ghforge "github.com/git-pkgs/forge/github"
glforge "github.com/git-pkgs/forge/gitlab"
"github.com/git-pkgs/forge/internal/config"
"github.com/git-pkgs/forge/tangled"
)

var (
Expand Down Expand Up @@ -82,9 +83,10 @@ func ResetTestForge() {
}

var builders = forges.ForgeBuilders{
GitHub: ghforge.NewWithBase,
GitLab: glforge.New,
Gitea: gitea.New,
GitHub: ghforge.NewWithBase,
GitLab: glforge.New,
Gitea: gitea.New,
Tangled: tangled.New,
}

// Repo figures out the forge, owner, and repo name from flags or the current
Expand Down Expand Up @@ -225,6 +227,7 @@ func newClient(domain string) *forges.Client {
"gitlab.com": glforge.New("https://gitlab.com", TokenForDomain("gitlab.com"), hc),
"codeberg.org": gitea.New("https://codeberg.org", TokenForDomain("codeberg.org"), hc),
"bitbucket.org": bitbucket.New(TokenForDomain("bitbucket.org"), hc),
"tangled.org": tangled.New("https://tangled.org", TokenForDomain("tangled.org"), hc),
}
for d, f := range defaults {
opts = append(opts, forges.WithForge(d, f))
Expand All @@ -251,6 +254,8 @@ func forgeForType(forgeType, baseURL, token string, hc *http.Client) forges.Forg
return glforge.New(baseURL, token, hc)
case "github":
return ghforge.NewWithBase(baseURL, token, hc)
case "tangled":
return tangled.New(baseURL, token, hc)
}
return nil
}
Expand Down Expand Up @@ -323,6 +328,10 @@ func TokenForDomainEnv(domain string) string {
if t := os.Getenv("BITBUCKET_TOKEN"); t != "" {
return t
}
case "tangled.org":
if t := os.Getenv("TANGLED_TOKEN"); t != "" {
return t
}
}

// FORGE_TOKEN is a fallback for any domain without a specific token.
Expand Down Expand Up @@ -367,6 +376,8 @@ func defaultDomainForType(forgeType string) string {
return "codeberg.org"
case "bitbucket":
return "bitbucket.org"
case "tangled":
return "tangled.org"
default:
return "github.com"
}
Expand Down
11 changes: 10 additions & 1 deletion internal/resolve/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func clearTokenEnv(t *testing.T) {
for _, v := range []string{
"GITHUB_TOKEN", "GH_TOKEN",
"GITLAB_TOKEN", "GLAB_TOKEN",
"FORGEJO_TOKEN", "GITEA_TOKEN", "BITBUCKET_TOKEN",
"FORGEJO_TOKEN", "GITEA_TOKEN", "BITBUCKET_TOKEN", "TANGLED_TOKEN",
"FORGE_TOKEN",
} {
t.Setenv(v, "")
Expand Down Expand Up @@ -129,6 +129,14 @@ func TestTokenForDomain(t *testing.T) {
}
t.Setenv("FORGEJO_TOKEN", "")
t.Setenv("GITEA_TOKEN", "")

// Tangled
t.Setenv("TANGLED_TOKEN", "tangled-tok")
got = TokenForDomain("tangled.org")
if got != "tangled-tok" {
t.Errorf("expected tangled-tok, got %q", got)
}
t.Setenv("TANGLED_TOKEN", "")
}

func TestTokenForDomainEnvSpecificOverridesFallback(t *testing.T) {
Expand Down Expand Up @@ -175,6 +183,7 @@ func TestDomain(t *testing.T) {
{"gitea", "codeberg.org"},
{"forgejo", "codeberg.org"},
{"bitbucket", "bitbucket.org"},
{"tangled", "tangled.org"},
{"unknown", "github.com"},
}

Expand Down
69 changes: 69 additions & 0 deletions tangled/branches.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package tangled

import (
"context"
"net/url"
"strings"

forges "github.com/git-pkgs/forge"
)

type branchService struct {
f *tangledForge
}

func (s *branchService) List(ctx context.Context, owner, repo string, opts forges.ListBranchOpts) ([]forges.Branch, error) {
repoDID, err := s.f.repoDID(ctx, owner, repo)
if err != nil {
return nil, err
}

var branches []forges.Branch
cursor := ""
for {
params := url.Values{}
params.Set("repo", repoDID)
addLimit(params, perPage(opts.Limit, opts.PerPage))
if cursor != "" {
params.Set("cursor", cursor)
}

var raw any
if err := s.f.xrpc(ctx, xrpcListBranches, params, &raw); err != nil {
return nil, err
}
items, next := collection(raw, "branches", "refs", "values")
for _, item := range items {
switch v := item.(type) {
case string:
branches = append(branches, forges.Branch{Name: strings.TrimPrefix(v, "refs/heads/")})
case map[string]any:
name := stringField(v, "name", "ref")
if name == "" {
continue
}
branches = append(branches, forges.Branch{
Name: strings.TrimPrefix(name, "refs/heads/"),
SHA: stringField(v, "sha", "oid", "target", "commit", "hash"),
Default: boolField(v, "default", "isDefault"),
Protected: boolField(v, "protected"),
})
}
if limitReached(len(branches), opts.Limit) {
return branches, nil
}
}
if next == "" {
return branches, nil
}
cursor = next
}
}

func (s *branchService) Create(context.Context, string, string, string, string) (*forges.Branch, error) {
return nil, forges.ErrNotSupported
}

func (s *branchService) Delete(context.Context, string, string, string) error {
return forges.ErrNotSupported
}
74 changes: 74 additions & 0 deletions tangled/files.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package tangled

import (
"context"
"net/url"
"path"
"strings"

forges "github.com/git-pkgs/forge"
)

type fileService struct {
f *tangledForge
}

func (s *fileService) Get(context.Context, string, string, string, string) (*forges.FileContent, error) {
return nil, forges.ErrNotSupported
}

func (s *fileService) List(ctx context.Context, owner, repo, filePath, ref string) ([]forges.FileEntry, error) {
repoDID, err := s.f.repoDID(ctx, owner, repo)
if err != nil {
return nil, err
}
if ref == "" {
ref = "HEAD"
}

params := url.Values{}
params.Set("repo", repoDID)
params.Set("ref", ref)
params.Set("path", strings.Trim(filePath, "/"))

var raw any
if err := s.f.xrpc(ctx, xrpcGetTree, params, &raw); err != nil {
return nil, err
}

items, _ := collection(raw, "tree", "entries", "values")
entries := make([]forges.FileEntry, 0, len(items))
for _, item := range items {
v, ok := item.(map[string]any)
if !ok {
continue
}
name := stringField(v, "name")
entryPath := strings.TrimPrefix(stringField(v, "path"), "/")
if entryPath == "" {
entryPath = strings.TrimPrefix(path.Join(filePath, name), "/")
}
entryType := stringField(v, "type")
if entryType == "" {
entryType = typeFromMode(stringField(v, "mode"))
}
entries = append(entries, forges.FileEntry{
Name: name,
Path: entryPath,
Type: entryType,
Size: int64Field(v, "size"),
})
}
return entries, nil
}

func typeFromMode(mode string) string {
switch {
case strings.HasPrefix(mode, "04") || mode == "tree" || mode == "dir":
return "dir"
case mode == "symlink" || mode == "120000":
return "symlink"
default:
return "file"
}
}
Loading
Loading