Skip to content

Commit 29ca5ae

Browse files
authored
Route to tracking only on exact remote match (#62)
Branch confirmation creates a new local branch whenever the entered name does not match an existing remote ref. Previously any "/" in the name forced tracking, which broke prefixed conventions like feature/foo by attempting to track a remote that does not exist. The routing is extracted into a pure helper so it can be tested against a real git fixture without the full TUI.
1 parent 9d434fc commit 29ca5ae

3 files changed

Lines changed: 142 additions & 3 deletions

File tree

pkg/git/branch.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os/exec"
7+
"slices"
78
"strings"
89
)
910

@@ -69,6 +70,39 @@ func RemoteBranches(dir string) ([]string, error) {
6970
return result, nil
7071
}
7172

73+
// IsRemoteBranch reports whether name exactly matches a known remote branch.
74+
// Returns false on error so callers can fall through to local creation.
75+
func IsRemoteBranch(dir, name string) bool {
76+
remotes, err := RemoteBranches(dir)
77+
if err != nil {
78+
return false
79+
}
80+
return slices.Contains(remotes, name)
81+
}
82+
83+
// BranchAction is the routing decision for a confirmed branch name.
84+
type BranchAction int
85+
86+
const (
87+
ActionCreate BranchAction = iota
88+
ActionCheckout
89+
ActionCheckoutTracking
90+
)
91+
92+
// ResolveBranchAction decides how to act on a confirmed branch name.
93+
// Order: existing local -> Checkout; exact remote match -> CheckoutTracking;
94+
// otherwise -> Create. See spec-lj-0023.
95+
func ResolveBranchAction(dir, name string) BranchAction {
96+
switch {
97+
case BranchExists(dir, name):
98+
return ActionCheckout
99+
case IsRemoteBranch(dir, name):
100+
return ActionCheckoutTracking
101+
default:
102+
return ActionCreate
103+
}
104+
}
105+
72106
// BranchExists returns true if a local branch with the given name exists
73107
func BranchExists(dir, name string) bool {
74108
cmd := exec.CommandContext(context.Background(), "git", "-C", dir, "rev-parse", "--verify", "refs/heads/"+name)

pkg/git/branch_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package git
2+
3+
import (
4+
"context"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"testing"
9+
)
10+
11+
// initRepo creates a fresh git repo with one commit on the default branch
12+
// (renamed to "main"), and returns the directory.
13+
func initRepo(t *testing.T) string {
14+
t.Helper()
15+
dir := t.TempDir()
16+
17+
run := func(args ...string) {
18+
cmd := exec.CommandContext(context.Background(), "git", append([]string{"-C", dir}, args...)...)
19+
out, err := cmd.CombinedOutput()
20+
if err != nil {
21+
t.Fatalf("git %v failed: %v\n%s", args, err, out)
22+
}
23+
}
24+
25+
run("init", "-q", "-b", "main")
26+
run("config", "user.email", "[email protected]")
27+
run("config", "user.name", "test")
28+
run("config", "commit.gpgsign", "false")
29+
30+
if err := os.WriteFile(filepath.Join(dir, "README"), []byte("x\n"), 0o644); err != nil {
31+
t.Fatal(err)
32+
}
33+
run("add", "README")
34+
run("commit", "-q", "-m", "init")
35+
return dir
36+
}
37+
38+
// addRemoteRef pins refs/remotes/<ref> to current HEAD without any network.
39+
func addRemoteRef(t *testing.T, dir, ref string) {
40+
t.Helper()
41+
cmd := exec.CommandContext(context.Background(), "git", "-C", dir, "update-ref", "refs/remotes/"+ref, "HEAD")
42+
if out, err := cmd.CombinedOutput(); err != nil {
43+
t.Fatalf("update-ref %s failed: %v\n%s", ref, err, out)
44+
}
45+
}
46+
47+
func TestResolveBranchAction_slashedNewName_isCreate(t *testing.T) {
48+
// Bug case from lj-0023: a name containing "/" that does not match any
49+
// remote branch must route to ActionCreate, not ActionCheckoutTracking.
50+
dir := initRepo(t)
51+
addRemoteRef(t, dir, "origin/main")
52+
53+
got := ResolveBranchAction(dir, "feature/PROJ-1-foo")
54+
if got != ActionCreate {
55+
t.Errorf("ResolveBranchAction = %v, want ActionCreate", got)
56+
}
57+
}
58+
59+
func TestResolveBranchAction_existingLocal_isCheckout(t *testing.T) {
60+
dir := initRepo(t)
61+
62+
got := ResolveBranchAction(dir, "main")
63+
if got != ActionCheckout {
64+
t.Errorf("ResolveBranchAction = %v, want ActionCheckout", got)
65+
}
66+
}
67+
68+
func TestResolveBranchAction_existingRemote_isTracking(t *testing.T) {
69+
dir := initRepo(t)
70+
// Use a name that does not exist locally.
71+
addRemoteRef(t, dir, "origin/feature-x")
72+
73+
got := ResolveBranchAction(dir, "origin/feature-x")
74+
if got != ActionCheckoutTracking {
75+
t.Errorf("ResolveBranchAction = %v, want ActionCheckoutTracking", got)
76+
}
77+
}
78+
79+
func TestResolveBranchAction_plainName_isCreate(t *testing.T) {
80+
dir := initRepo(t)
81+
82+
got := ResolveBranchAction(dir, "PROJ-1-foo")
83+
if got != ActionCreate {
84+
t.Errorf("ResolveBranchAction = %v, want ActionCreate", got)
85+
}
86+
}
87+
88+
func TestIsRemoteBranch_exactMatchRequired(t *testing.T) {
89+
dir := initRepo(t)
90+
addRemoteRef(t, dir, "origin/feature/x")
91+
92+
if IsRemoteBranch(dir, "origin/feature/y") {
93+
t.Errorf("IsRemoteBranch matched non-existent sibling branch")
94+
}
95+
if !IsRemoteBranch(dir, "origin/feature/x") {
96+
t.Errorf("IsRemoteBranch did not match exact remote branch")
97+
}
98+
}
99+
100+
func TestIsRemoteBranch_nonGitDir_returnsFalse(t *testing.T) {
101+
dir := t.TempDir()
102+
if IsRemoteBranch(dir, "origin/main") {
103+
t.Errorf("IsRemoteBranch returned true for non-git dir")
104+
}
105+
}

pkg/tui/handlers_modal.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,10 @@ func (a *App) handleInputConfirmed(msg components.InputConfirmedMsg) (tea.Model,
123123
}
124124
case editBranch:
125125
if msg.Text != "" {
126-
switch {
127-
case git.BranchExists(a.gitRepoPath, msg.Text):
126+
switch git.ResolveBranchAction(a.gitRepoPath, msg.Text) {
127+
case git.ActionCheckout:
128128
return a, gitCheckoutBranch(a.gitRepoPath, msg.Text)
129-
case strings.Contains(msg.Text, "/"):
129+
case git.ActionCheckoutTracking:
130130
return a, gitCheckoutTracking(a.gitRepoPath, msg.Text)
131131
default:
132132
return a, gitCreateBranch(a.gitRepoPath, msg.Text)

0 commit comments

Comments
 (0)