fix: address verified review findings (branches-checkout-status-1)

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 17:38:53 +02:00
1 parent 6e523d969d
commit 58f0fc4bf9
27 files changed
+281 -65

No files matched your search

+15 -3
View File
@@ -29,13 +29,25 @@ type BranchConfig struct {
// CurrentBranchConfig reads the configuration of the checked-out branch, failing with
// ErrNotOnBranch when HEAD is detached or dir is not a checkout.
func CurrentBranchConfig(ctx context.Context, runner Runner, dir string) (BranchConfig, error) {
name, err := runner.Output(ctx, dir, false, "symbolic-ref", "--short", "HEAD")
if err != nil || name == "" {
return BranchConfig{}, ErrNotOnBranch
name, err := CurrentBranch(ctx, runner, dir)
if err != nil {
return BranchConfig{}, err
}
return ReadBranchConfig(ctx, runner, dir, name), nil
}
// CurrentBranch names the checked-out branch, failing with ErrNotOnBranch when HEAD is detached
// or dir is not a checkout. It reads the full ref because --short answers heads/NAME for a branch
// that shares its name with a tag.
func CurrentBranch(ctx context.Context, runner Runner, dir string) (string, error) {
ref, err := runner.Output(ctx, dir, false, "symbolic-ref", "--quiet", "HEAD")
name := strings.TrimPrefix(ref, "refs/heads/")
if err != nil || name == "" {
return "", ErrNotOnBranch
}
return name, nil
}
// ReadBranchConfig reads branch.<name>.remote, .merge and the keys pr checkout records.
// Missing or malformed keys stay empty.
func ReadBranchConfig(ctx context.Context, runner Runner, dir, name string) BranchConfig {
+17 -1
View File
@@ -5,13 +5,14 @@ package git
import (
"context"
"os/exec"
"testing"
"github.com/stretchr/testify/require"
)
func TestCurrentBranchConfig(t *testing.T) {
const head = "symbolic-ref --short HEAD"
const head = "symbolic-ref --quiet HEAD"
for _, test := range []struct {
name string
runner discoverRunner
@@ -39,3 +40,18 @@ func TestCurrentBranchConfig(t *testing.T) {
})
}
}
func TestCurrentBranchIgnoresATagWithTheSameName(t *testing.T) {
dir := t.TempDir()
for _, args := range [][]string{
{"init", "--quiet", "--initial-branch=rel-2"},
{"-c", "user.name=t", "-c", "user.email=t@example.test", "commit", "--quiet", "--allow-empty", "--message=x"},
{"tag", "rel-2"},
} {
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput()
require.NoError(t, err, string(out))
}
name, err := CurrentBranch(context.Background(), System{}, dir)
require.NoError(t, err)
require.Equal(t, "rel-2", name)
}
+14 -6
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"strings"
)
@@ -34,6 +35,9 @@ type FindCleanBranchOptions struct {
DefaultBranch string
PreferredNames, MatchingRemotes []string
IgnoreSHA bool
// PullNumber is the pull request being cleaned; a branch that pr checkout recorded for it
// wins over branches that merely share the head branch's name.
PullNumber int64
}
// FindCleanBranch locates one local branch for PR cleanup without mutating Git state.
@@ -49,8 +53,8 @@ func FindCleanBranch(ctx context.Context, runner Runner, opts FindCleanBranchOpt
return "", "", err
}
type candidate struct {
name, remote string
matched bool
name, remote string
matched, recorded bool
}
setOf := func(names []string) map[string]bool {
out := map[string]bool{}
@@ -80,8 +84,9 @@ func FindCleanBranch(ctx context.Context, runner Runner, opts FindCleanBranchOpt
if len(parts) == 3 {
upstream = parts[2]
}
recorded := opts.PullNumber > 0 && ReadBranchConfig(ctx, runner, opts.Directory, name).PullNumber == opts.PullNumber
if opts.IgnoreSHA {
if name != opts.HeadBranch && !preferred[name] {
if name != opts.HeadBranch && !preferred[name] && !recorded {
continue
}
} else if opts.HeadSHA == "" || sha != opts.HeadSHA {
@@ -107,7 +112,7 @@ func FindCleanBranch(ctx context.Context, runner Runner, opts FindCleanBranchOpt
remoteName = remote
}
}
candidates = append(candidates, candidate{name: name, remote: remoteName, matched: matched})
candidates = append(candidates, candidate{name: name, remote: remoteName, matched: matched, recorded: recorded})
}
if len(candidates) == 0 {
if opts.IgnoreSHA {
@@ -119,13 +124,16 @@ Retry with --ignore-sha to select by branch name`, opts.HeadSHA)
}
selected := []candidate{}
for _, item := range candidates {
if item.matched || preferred[item.name] || item.name == opts.HeadBranch {
if item.matched || item.recorded || preferred[item.name] || item.name == opts.HeadBranch {
selected = append(selected, item)
}
}
if len(selected) == 0 {
selected = candidates
}
if recorded := slices.DeleteFunc(slices.Clone(selected), func(item candidate) bool { return !item.recorded }); len(recorded) > 0 {
selected = recorded
}
if len(selected) > 1 {
strong := make([]candidate, 0, len(selected))
for _, item := range selected {
@@ -183,7 +191,7 @@ func CleanPull(ctx context.Context, runner Runner, opts CleanOptions) (CleanResu
if _, err := run("rev-parse", "--verify", "refs/heads/"+opts.DefaultBranch+"^{commit}"); err != nil {
return CleanResult{}, fmt.Errorf("default branch is unavailable; create or fetch it before cleaning: %w", err)
}
current, err := run("symbolic-ref", "--short", "HEAD")
current, err := CurrentBranch(ctx, runner, opts.Directory)
if err == nil && current == opts.LocalBranch {
if _, err := run("switch", "--quiet", "--", opts.DefaultBranch); err != nil {
return CleanResult{}, fmt.Errorf("could not switch to %s before deleting %s: %w", opts.DefaultBranch, opts.LocalBranch, err)
+24 -1
View File
@@ -15,7 +15,7 @@ type cleanRunner struct {
calls []string
dirty, missingLocal, missingDefault, onFeature bool
branches string
remoteTracking map[string]string
remoteTracking, config map[string]string
}
func (r *cleanRunner) Output(_ context.Context, dir string, prompt bool, args ...string) (string, error) {
@@ -44,6 +44,11 @@ func (r *cleanRunner) Output(_ context.Context, dir string, prompt bool, args ..
}
return "", &CommandError{ExitCode: 1}
}
case "config":
if value, ok := r.config[args[2]]; ok {
return value, nil
}
return "", &CommandError{ExitCode: 1}
case "status":
if r.dirty {
return " M file", nil
@@ -81,6 +86,24 @@ func TestFindCleanBranchIgnoreSHAUsesName(t *testing.T) {
require.Equal(t, "origin", remote)
}
func TestFindCleanBranchIgnoreSHAPrefersTheBranchRecordedForThePull(t *testing.T) {
runner := &cleanRunner{
branches: "aaa111\tfeature\t\nbbb222\tbob/feature\t\n",
config: map[string]string{"branch.bob/feature.tea-pr": "1"},
}
options := FindCleanBranchOptions{
Directory: "/work", HeadSHA: "abc123", HeadBranch: "feature", IgnoreSHA: true, PullNumber: 1, MatchingRemotes: []string{"origin"},
}
branch, _, err := FindCleanBranch(context.Background(), runner, options)
require.NoError(t, err)
require.Equal(t, "bob/feature", branch)
// Without the record, OWNER/BRANCH is only preferred by name and the unrelated branch stays a candidate.
runner.config = nil
options.PreferredNames = []string{"bob/feature"}
_, _, err = FindCleanBranch(context.Background(), runner, options)
require.ErrorContains(t, err, "multiple local branches")
}
func TestFindCleanBranchRejectsAmbiguity(t *testing.T) {
runner := &cleanRunner{branches: "abc123\tone\t\nabc123\ttwo\t\n"}
_, _, err := FindCleanBranch(context.Background(), runner, FindCleanBranchOptions{
+1 -1
View File
@@ -92,7 +92,7 @@ func DiscoverRemote(ctx context.Context, runner Runner, dir, preferred string, r
// CurrentUpstreamRemote names the checked-out branch and the remote it tracks; each is "" when
// HEAD is detached or the branch tracks no remote.
func CurrentUpstreamRemote(ctx context.Context, runner Runner, dir string) (branch, remote string) {
branch, err := runner.Output(ctx, dir, false, "symbolic-ref", "--short", "HEAD")
branch, err := CurrentBranch(ctx, runner, dir)
if err != nil {
return "", ""
}
+2 -2
View File
@@ -23,7 +23,7 @@ func (r discoverRunner) Output(_ context.Context, dir string, prompt bool, args
}
func TestDiscoverRemote(t *testing.T) {
const head, tracking, base = "symbolic-ref --short HEAD", "config --get branch.main.remote", "config --get branch.main.tea-base-remote"
const head, tracking, base = "symbolic-ref --quiet HEAD", "config --get branch.main.remote", "config --get branch.main.tea-base-remote"
for _, test := range []struct {
name string
runner discoverRunner
@@ -55,7 +55,7 @@ func TestDiscoverRemote(t *testing.T) {
}
func TestDiscoverRemoteSkipsUnusableRemotes(t *testing.T) {
const head, tracking, base = "symbolic-ref --short HEAD", "config --get branch.main.remote", "config --get branch.main.tea-base-remote"
const head, tracking, base = "symbolic-ref --quiet HEAD", "config --get branch.main.remote", "config --get branch.main.tea-base-remote"
// gitea makes each named remote usable; a name=repository pair names its repository.
gitea := func(names ...string) func(string) string {
return func(remote string) string {