Skip to content

Commit 967ed9a

Browse files
authored
fix: resolve branch names case-insensitively against remote refs (#70)
When creating or checking out a branch, wt now queries `git ls-remote --heads origin` and matches the user-provided branch name case-insensitively. If the remote has a differently-cased branch (e.g. `Feature/add-logging` vs `feature/add-logging`), wt uses the remote's casing and prints a note to stderr. Fixes #69
1 parent 371db5a commit 967ed9a

2 files changed

Lines changed: 115 additions & 0 deletions

File tree

main.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,39 @@ func branchExists(branch string) bool {
388388
return cmd.Run() == nil
389389
}
390390

391+
// matchRemoteBranch parses git ls-remote --heads output and returns the
392+
// correctly-cased branch name if a case-insensitive match is found.
393+
// Returns the original branch name if no match is found.
394+
func matchRemoteBranch(branch string, lsRemoteOutput string) string {
395+
lowerBranch := strings.ToLower(branch)
396+
for _, line := range strings.Split(lsRemoteOutput, "\n") {
397+
line = strings.TrimSpace(line)
398+
if line == "" {
399+
continue
400+
}
401+
parts := strings.SplitN(line, "\t", 2)
402+
if len(parts) != 2 {
403+
continue
404+
}
405+
ref := strings.TrimPrefix(parts[1], "refs/heads/")
406+
if strings.ToLower(ref) == lowerBranch {
407+
return ref
408+
}
409+
}
410+
return branch
411+
}
412+
413+
// resolveRemoteBranchCase calls git ls-remote and resolves the branch name
414+
// using case-insensitive matching. Returns the remote's casing if found.
415+
func resolveRemoteBranchCase(branch string) string {
416+
cmd := exec.Command("git", "ls-remote", "--heads", "origin")
417+
output, err := cmd.Output()
418+
if err != nil {
419+
return branch
420+
}
421+
return matchRemoteBranch(branch, string(output))
422+
}
423+
391424
func buildWorktreePath(info repoInfo, branch string) (string, error) {
392425
rendered, err := renderWorktreePath(info, branch)
393426
if err != nil {
@@ -867,6 +900,14 @@ var checkoutCmd = &cobra.Command{
867900
} else {
868901
branch = args[0]
869902
}
903+
904+
// Resolve case-insensitive match against remote branches
905+
resolved := resolveRemoteBranchCase(branch)
906+
if resolved != branch {
907+
fmt.Fprintf(os.Stderr, "Note: using remote branch name %q (you typed %q)\n", resolved, branch)
908+
branch = resolved
909+
}
910+
870911
info, err := getRepoInfo()
871912
if err != nil {
872913
return err
@@ -944,6 +985,13 @@ var createCmd = &cobra.Command{
944985
base = args[1]
945986
}
946987

988+
// Resolve case-insensitive match against remote branches
989+
resolved := resolveRemoteBranchCase(branch)
990+
if resolved != branch {
991+
fmt.Fprintf(os.Stderr, "Note: using remote branch name %q (you typed %q)\n", resolved, branch)
992+
branch = resolved
993+
}
994+
947995
info, err := getRepoInfo()
948996
if err != nil {
949997
return err

main_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1451,6 +1451,73 @@ func TestRunHooksEnvVarsAvailable(t *testing.T) {
14511451
}
14521452
}
14531453

1454+
func TestMatchRemoteBranch(t *testing.T) {
1455+
lsRemoteOutput := `abc123 refs/heads/main
1456+
def456 refs/heads/Feature/add-logging
1457+
ghi789 refs/heads/Feature/add-logging-v2
1458+
jkl012 refs/heads/Bugfix/fixLogin
1459+
mno345 refs/heads/develop
1460+
`
1461+
1462+
tests := []struct {
1463+
name string
1464+
input string
1465+
want string
1466+
}{
1467+
{
1468+
name: "Exact match returns as-is",
1469+
input: "Feature/add-logging",
1470+
want: "Feature/add-logging",
1471+
},
1472+
{
1473+
name: "Lowercase input matches uppercase remote",
1474+
input: "feature/add-logging",
1475+
want: "Feature/add-logging",
1476+
},
1477+
{
1478+
name: "All uppercase input matches mixed-case remote",
1479+
input: "FEATURE/ADD-LOGGING",
1480+
want: "Feature/add-logging",
1481+
},
1482+
{
1483+
name: "Case mismatch in middle of name",
1484+
input: "bugfix/fixlogin",
1485+
want: "Bugfix/fixLogin",
1486+
},
1487+
{
1488+
name: "No match returns original",
1489+
input: "nonexistent-branch",
1490+
want: "nonexistent-branch",
1491+
},
1492+
{
1493+
name: "Exact match for main",
1494+
input: "main",
1495+
want: "main",
1496+
},
1497+
{
1498+
name: "Partial name should not match",
1499+
input: "Feature/add",
1500+
want: "Feature/add",
1501+
},
1502+
}
1503+
1504+
for _, tt := range tests {
1505+
t.Run(tt.name, func(t *testing.T) {
1506+
got := matchRemoteBranch(tt.input, lsRemoteOutput)
1507+
if got != tt.want {
1508+
t.Errorf("matchRemoteBranch(%q) = %q, want %q", tt.input, got, tt.want)
1509+
}
1510+
})
1511+
}
1512+
}
1513+
1514+
func TestMatchRemoteBranchEmptyOutput(t *testing.T) {
1515+
got := matchRemoteBranch("feature/foo", "")
1516+
if got != "feature/foo" {
1517+
t.Errorf("matchRemoteBranch with empty output = %q, want %q", got, "feature/foo")
1518+
}
1519+
}
1520+
14541521
func TestRunHooksMultiplePostContinueOnFailure(t *testing.T) {
14551522
if testing.Short() {
14561523
t.Skip("skipping in short mode")

0 commit comments

Comments
 (0)