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
77 changes: 77 additions & 0 deletions git/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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, owner, repo, branch string, forceRefresh bool) (string, error) {
if branch == "" {
curr, err := runGit(ctx, "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, "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 {
if pr.Head.Ref == branch || strings.HasSuffix(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.
_, _ = runGit(ctx, "config", "--local", configKey, baseBranch)

return baseBranch, nil
}

func runGit(ctx context.Context, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
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
}
127 changes: 127 additions & 0 deletions git/git_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package git

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

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

type mockPRService struct {
forges.PullRequestService
prs []forges.PullRequest
}

func (m *mockPRService) List(ctx context.Context, owner, repo string, opts forges.ListPROpts) ([]forges.PullRequest, error) {
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 and run git init
tmpDir := t.TempDir()
origWd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer func() {
_ = os.Chdir(origWd)
}()

if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}

// Initialize git repo
cmd := exec.Command("git", "init")
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
_ = exec.Command("git", "config", "user.name", "test").Run()
_ = exec.Command("git", "config", "user.email", "test@example.com").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)
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, "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
_ = exec.Command("git", "config", "--unset", "branch.feature-xyz.forge-merge-base").Run()

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

gotBase, err = GetOrFetchBaseBranch(ctx, mock, "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, "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")
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, "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)
}
}
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/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().BoolVarP(&flagRefresh, "refresh", "r", 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
26 changes: 24 additions & 2 deletions internal/cli/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ func prViewCmd() *cobra.Command {
return fmt.Errorf("getting PR #%d: %w", number, err)
}

if pr.State == "open" && pr.Head.Ref != "" && pr.Base.Ref != "" {
headBranch := pr.Head.Ref
if exec.CommandContext(cmd.Context(), "git", "show-ref", "--verify", "--quiet", "refs/heads/"+headBranch).Run() == nil {
_ = exec.CommandContext(cmd.Context(), "git", "config", "--local", fmt.Sprintf("branch.%s.forge-merge-base", headBranch), pr.Base.Ref).Run()
}
}

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

if flagHead != "" && pr.Base.Ref != "" {
_ = exec.CommandContext(cmd.Context(), "git", "config", "--local", fmt.Sprintf("branch.%s.forge-merge-base", flagHead), pr.Base.Ref).Run()
}

p := printer()
if p.Format == output.JSON {
return p.PrintJSON(pr)
Expand Down Expand Up @@ -630,13 +641,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 != "" {
_ = exec.CommandContext(ctx, "git", "config", "--local", fmt.Sprintf("branch.%s.forge-merge-base", localBranch), pr.Base.Ref).Run()
}

return nil
},
}

Expand Down
Loading