diff --git a/git/git.go b/git/git.go new file mode 100644 index 0000000..056fab1 --- /dev/null +++ b/git/git.go @@ -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 +} diff --git a/git/git_test.go b/git/git_test.go new file mode 100644 index 0000000..f1eaef9 --- /dev/null +++ b/git/git_test.go @@ -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) + } +} diff --git a/internal/cli/branch.go b/internal/cli/branch.go index 3f3ec77..3ce52b8 100644 --- a/internal/cli/branch.go +++ b/internal/cli/branch.go @@ -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" @@ -22,6 +23,7 @@ func init() { branchCmd.AddCommand(branchListCmd()) branchCmd.AddCommand(branchCreateCmd()) branchCmd.AddCommand(branchDeleteCmd()) + branchCmd.AddCommand(branchShowBaseCmd()) } func branchListCmd() *cobra.Command { @@ -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..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 +} diff --git a/internal/cli/branch_test.go b/internal/cli/branch_test.go index 134e7fb..09293ae 100644 --- a/internal/cli/branch_test.go +++ b/internal/cli/branch_test.go @@ -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 { diff --git a/internal/cli/pr.go b/internal/cli/pr.go index 9007f07..0335566 100644 --- a/internal/cli/pr.go +++ b/internal/cli/pr.go @@ -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) } @@ -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) @@ -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 }, }