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
40 changes: 40 additions & 0 deletions internal/cli/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"

"github.com/git-pkgs/forge"
"github.com/git-pkgs/forge/internal/git"
"github.com/git-pkgs/forge/internal/output"
"github.com/git-pkgs/forge/internal/resolve"
"github.com/spf13/cobra"
Expand All @@ -22,6 +23,7 @@ func init() {
branchCmd.AddCommand(branchListCmd())
branchCmd.AddCommand(branchCreateCmd())
branchCmd.AddCommand(branchDeleteCmd())
branchCmd.AddCommand(branchShowBaseCmd())
}

func branchListCmd() *cobra.Command {
Expand Down Expand Up @@ -153,3 +155,41 @@ func branchDeleteCmd() *cobra.Command {
cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation")
return cmd
}

func branchShowBaseCmd() *cobra.Command {
var flagRefresh bool

cmd := &cobra.Command{
Use: "show-base [branch]",
Short: "Show the base branch for a branch",
Long: `Show the base branch for the specified branch (defaults to the current branch).

It first checks for a cached base branch name under the local git config key
'branch.<branch>.forge-merge-base' in .git/config. If not found, it queries the
forge API for an open pull request, caches the resolved target branch name back in
the local git configuration, and returns it.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var branch string
if len(args) > 0 {
branch = args[0]
}

forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType)
if err != nil {
return err
}

base, err := git.GetOrFetchBaseBranch(cmd.Context(), forge, "", owner, repoName, branch, flagRefresh)
if err != nil {
return err
}

fmt.Println(base)
return nil
},
}

cmd.Flags().BoolVar(&flagRefresh, "refresh", false, "Force query the forge API and update cached base branch")
return cmd
}
7 changes: 4 additions & 3 deletions internal/cli/branch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import "testing"
func TestBranchSubcommands(t *testing.T) {
subs := branchCmd.Commands()
want := map[string]bool{
"list": false,
"create": false,
"delete": false,
"list": false,
"create": false,
"delete": false,
"show-base": false,
}

for _, cmd := range subs {
Expand Down
27 changes: 25 additions & 2 deletions internal/cli/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/git-pkgs/forge"
"github.com/git-pkgs/forge/internal/git"
"github.com/git-pkgs/forge/internal/output"
"github.com/git-pkgs/forge/internal/resolve"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -80,6 +81,13 @@ func prViewCmd() *cobra.Command {
return fmt.Errorf("getting PR #%d: %w", number, err)
}

if pr.State == "open" && pr.Head.Ref != "" && pr.Base.Ref != "" && pr.Head.Fork == nil {
headBranch := pr.Head.Ref
if exec.CommandContext(cmd.Context(), "git", "show-ref", "--verify", "--quiet", "refs/heads/"+headBranch).Run() == nil {
_ = git.SetBaseBranch(cmd.Context(), "", headBranch, pr.Base.Ref)
}
}

if flagWeb {
return openBrowser(pr.HTMLURL)
}
Expand Down Expand Up @@ -297,6 +305,10 @@ func prCreateCmd() *cobra.Command {
return fmt.Errorf("creating pull request: %w", err)
}

if flagHead != "" && pr.Base.Ref != "" {
_ = git.SetBaseBranch(cmd.Context(), "", flagHead, pr.Base.Ref)
}

p := printer()
if p.Format == output.JSON {
return p.PrintJSON(pr)
Expand Down Expand Up @@ -630,13 +642,24 @@ The argument can be a PR number or a full URL:
localBranch = defaultLocalBranch(pr)
}

var errCheckout error
// A pull ref isn't present on the fork remote, only on origin, so
// route it through the same-repo path even for fork PRs.
if pr.Head.Fork != nil && !isFullRef(remoteRef) {
return checkoutForkPR(ctx, domain, pr, remoteRef, localBranch, flagRemoteName, flagDetach, flagForce)
errCheckout = checkoutForkPR(ctx, domain, pr, remoteRef, localBranch, flagRemoteName, flagDetach, flagForce)
} else {
errCheckout = checkoutSameRepoPR(ctx, remoteRef, localBranch, flagDetach, flagForce)
}

return checkoutSameRepoPR(ctx, remoteRef, localBranch, flagDetach, flagForce)
if errCheckout != nil {
return errCheckout
}

if !flagDetach && localBranch != "" && pr.Base.Ref != "" {
_ = git.SetBaseBranch(ctx, "", localBranch, pr.Base.Ref)
}

return nil
},
}

Expand Down
94 changes: 94 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Package git provides helpers for interacting with local git repositories and configurations.
package git

import (
"context"
"fmt"
"os/exec"
"strings"

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

// GetOrFetchBaseBranch returns the base branch of the given branch.
// It first checks the local git configuration for a cached value.
// If not found, it queries the forge API for an open pull request for the branch,
// caches the base branch name in the local git configuration, and returns it.
// If branch is empty, it uses the current branch.
func GetOrFetchBaseBranch(ctx context.Context, f forges.Forge, dir, owner, repo, branch string, forceRefresh bool) (string, error) {
if branch == "" {
curr, err := runGit(ctx, dir, "branch", "--show-current")
if err != nil {
return "", fmt.Errorf("failed to get current branch: %w", err)
}
branch = curr
}
if branch == "" {
return "", fmt.Errorf("empty branch name")
}

// 1. Check local git config
configKey := fmt.Sprintf("branch.%s.forge-merge-base", branch)
if !forceRefresh {
if cached, err := runGit(ctx, dir, "config", "--get", configKey); err == nil && cached != "" {
return cached, nil
}
}

// 2. Fetch base branch via forge API
prs, err := f.PullRequests().List(ctx, owner, repo, forges.ListPROpts{
State: "open",
Head: branch,
})
if err != nil {
return "", fmt.Errorf("failed to list pull requests: %w", err)
}

var baseBranch string
for _, pr := range prs {
// GitLab, Gitea, and Bitbucket do not support filtering by Head on the server side,
// so we must filter client-side. We check if the head branch ref matches the requested branch.
if pr.Head.Ref == branch {
baseBranch = pr.Base.Ref
break
}
}

if baseBranch == "" {
return "", fmt.Errorf("no open pull request found for branch %q", branch)
}

// 3. Cache the resolved base branch in local git config
// Even if caching fails, we still return the resolved base branch.
_ = SetBaseBranch(ctx, dir, branch, baseBranch)

return baseBranch, nil
}

// SetBaseBranch caches the base branch for a branch in the local git configuration.
func SetBaseBranch(ctx context.Context, dir, branch, base string) error {
if branch == "" {
return fmt.Errorf("empty branch name")
}
if base == "" {
return fmt.Errorf("empty base branch name")
}
configKey := fmt.Sprintf("branch.%s.forge-merge-base", branch)
_, err := runGit(ctx, dir, "config", "--local", configKey, base)
return err
}

func runGit(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if stderr.Len() > 0 {
return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
}
return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
return strings.TrimSpace(string(out)), nil
}
132 changes: 132 additions & 0 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package git

import (
"context"
"os/exec"
"testing"

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

type mockPRService struct {
forges.PullRequestService
prs []forges.PullRequest
t *testing.T
expectedHead string
}

func (m *mockPRService) List(ctx context.Context, owner, repo string, opts forges.ListPROpts) ([]forges.PullRequest, error) {
if m.expectedHead != "" && opts.Head != m.expectedHead {
m.t.Errorf("expected opts.Head to be %q, got %q", m.expectedHead, opts.Head)
}
return m.prs, nil
}

type mockForge struct {
forges.Forge
prService *mockPRService
}

func (m *mockForge) PullRequests() forges.PullRequestService {
return m.prService
}

func TestGetOrFetchBaseBranch(t *testing.T) {
// Create temporary directory
tmpDir := t.TempDir()

// Initialize git repo in tmpDir
cmd := exec.Command("git", "init")
cmd.Dir = tmpDir
if err := cmd.Run(); err != nil {
t.Skip("git not available, skipping test")
}

// We also need to configure a dummy user so git commands don't fail
cmdName := exec.Command("git", "config", "user.name", "test")
cmdName.Dir = tmpDir
_ = cmdName.Run()

cmdEmail := exec.Command("git", "config", "user.email", "test@example.com")
cmdEmail.Dir = tmpDir
_ = cmdEmail.Run()

// 1. Test cached config
// Set config for branch "feature-xyz"
branch := "feature-xyz"
wantBase := "main"
cmdSet := exec.Command("git", "config", "branch.feature-xyz.forge-merge-base", wantBase)
cmdSet.Dir = tmpDir
if err := cmdSet.Run(); err != nil {
t.Fatal(err)
}

ctx := context.Background()
// Pass nil Forge client since it should not be called when cached
gotBase, err := GetOrFetchBaseBranch(ctx, nil, tmpDir, "owner", "repo", branch, false)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if gotBase != wantBase {
t.Errorf("expected base branch %q, got %q", wantBase, gotBase)
}

// 2. Test fetching from forge
// Delete the cached config first
cmdUnset := exec.Command("git", "config", "--unset", "branch.feature-xyz.forge-merge-base")
cmdUnset.Dir = tmpDir
_ = cmdUnset.Run()

mock := &mockForge{
prService: &mockPRService{
t: t,
expectedHead: branch,
prs: []forges.PullRequest{
{
Number: 1,
State: "open",
Head: forges.PRBranch{
Ref: branch,
},
Base: forges.PRBranch{
Ref: "develop",
},
},
},
},
}

gotBase, err = GetOrFetchBaseBranch(ctx, mock, tmpDir, "owner", "repo", branch, false)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if gotBase != "develop" {
t.Errorf("expected base branch 'develop', got %q", gotBase)
}

// Verify it was cached in git config
cachedVal, err := runGit(ctx, tmpDir, "config", "--get", "branch.feature-xyz.forge-merge-base")
if err != nil {
t.Fatalf("expected to read config, got: %v", err)
}
if cachedVal != "develop" {
t.Errorf("expected cached value to be 'develop', got %q", cachedVal)
}

// 3. Test forceRefresh bypassing config cache
// Reset config to 'main'
cmdSet = exec.Command("git", "config", "branch.feature-xyz.forge-merge-base", "main")
cmdSet.Dir = tmpDir
if err := cmdSet.Run(); err != nil {
t.Fatal(err)
}

// Calling with forceRefresh=true should bypass the "main" cache and get "develop" from mock
gotBase, err = GetOrFetchBaseBranch(ctx, mock, tmpDir, "owner", "repo", branch, true)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if gotBase != "develop" {
t.Errorf("expected base branch 'develop', got %q", gotBase)
}
}
Loading