From ba5e3492111e26a6a38d0084c6c9386376fe28ac Mon Sep 17 00:00:00 2001 From: Clay Bridges Date: Thu, 9 Apr 2026 15:43:23 -0400 Subject: [PATCH 1/5] Prevent delete/rename of checked-out branches, add --force to detach HEAD --- cmd/git-folder/main.go | 67 ++++++++++++ cmd/git-folder/main_test.go | 213 ++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) diff --git a/cmd/git-folder/main.go b/cmd/git-folder/main.go index 3d3f775..596f79c 100644 --- a/cmd/git-folder/main.go +++ b/cmd/git-folder/main.go @@ -42,6 +42,58 @@ func confirm(prompt string) bool { return answer == "y" } +func checkBranchesNotCheckedOut(branches []string) error { + // Get current branch + out, _ := exec.Command("git", "symbolic-ref", "--quiet", "--short", "HEAD").Output() + current := strings.TrimSpace(string(out)) + + var checkedOutInWorktrees []string + + // Check if current branch is in list + for _, b := range branches { + if b == current { + if forceFlag { + // Detach HEAD at current commit so we can modify the branch + fmt.Printf("Detaching HEAD from '%s' (branch is being modified)\n", b) + if err := gitExec("checkout", "--detach"); err != nil { + return fmt.Errorf("failed to detach HEAD: %w", err) + } + } else { + return fmt.Errorf("cannot modify branch '%s': currently checked out (use --force to detach)", b) + } + } + } + + // Check worktrees + out, err := exec.Command("git", "worktree", "list", "--porcelain").Output() + if err != nil { + // If worktree command fails, continue (no worktrees or git doesn't support it) + return nil + } + + lines := strings.Split(string(out), "\n") + for _, line := range lines { + if !strings.HasPrefix(line, "branch ") { + continue + } + wtBranch := strings.TrimPrefix(line, "branch refs/heads/") + for _, b := range branches { + if b == wtBranch { + checkedOutInWorktrees = append(checkedOutInWorktrees, b) + } + } + } + + if len(checkedOutInWorktrees) > 0 { + if len(checkedOutInWorktrees) == 1 { + return fmt.Errorf("cannot modify branch '%s': checked out in a worktree", checkedOutInWorktrees[0]) + } + return fmt.Errorf("cannot modify branches: checked out in worktrees: %s", strings.Join(checkedOutInWorktrees, ", ")) + } + + return nil +} + func main() { if len(os.Args) < 2 { usage() @@ -205,6 +257,11 @@ func cmdDelete(args []string) error { return fmt.Errorf("no branches in folder %s/", name) } + // Check if any branches are checked out + if err := checkBranchesNotCheckedOut(branches); err != nil { + return err + } + fmt.Printf("delete all branches in folder %s/:\n", name) for _, b := range branches { fmt.Printf(" %s\n", b) @@ -254,6 +311,11 @@ func cmdDeleteUpto(args []string) error { return fmt.Errorf("no numbered branches below %v in folder %s/", n, folderName) } + // Check if any branches to delete are checked out + if err := checkBranchesNotCheckedOut(toDelete); err != nil { + return err + } + fmt.Printf("keep:\n") for _, b := range toKeep { fmt.Printf(" %s\n", b) @@ -292,6 +354,11 @@ func cmdRename(args []string) error { return fmt.Errorf("no branches in folder %s/", oldName) } + // Check if any source branches are checked out + if err := checkBranchesNotCheckedOut(sources); err != nil { + return err + } + // Check for conflicts existing, _ := folder.Enumerate(newName) if len(existing) > 0 { diff --git a/cmd/git-folder/main_test.go b/cmd/git-folder/main_test.go index 8b78072..4a1fd21 100644 --- a/cmd/git-folder/main_test.go +++ b/cmd/git-folder/main_test.go @@ -877,3 +877,216 @@ func TestForceFlag(t *testing.T) { forceFlag = false }) } + +// --- Checked-out branch tests --- + +func TestDeleteCheckedOutBranch(t *testing.T) { + t.Run("without force - should error", func(t *testing.T) { + dir := initTestRepo(t) + inDir(t, dir) + run(t, dir, "git", "checkout", "-b", "test/1") + + forceFlag = false + err := cmdDelete([]string{"test"}) + if err == nil { + t.Fatal("expected error when deleting checked-out branch") + } + if !strings.Contains(err.Error(), "currently checked out") { + t.Fatalf("expected 'currently checked out' error, got: %v", err) + } + + // Branch should still exist + branches := branchList(t, dir, "test/*") + if len(branches) != 1 { + t.Fatalf("expected branch to still exist, got: %v", branches) + } + }) + + t.Run("with force - should detach and delete", func(t *testing.T) { + dir := initTestRepo(t) + inDir(t, dir) + run(t, dir, "git", "checkout", "-b", "test/1") + + forceFlag = true + err := cmdDelete([]string{"test"}) + if err != nil { + t.Fatalf("unexpected error with --force: %v", err) + } + + // Branch should be deleted + branches := branchList(t, dir, "test/*") + if len(branches) != 0 { + t.Fatalf("expected branch to be deleted, got: %v", branches) + } + + // Should be in detached HEAD state + cmd := exec.Command("git", "symbolic-ref", "--quiet", "--short", "HEAD") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + // Command should fail (exit 1) when in detached HEAD + if err == nil { + t.Fatalf("expected detached HEAD (command should fail), got branch: %v", strings.TrimSpace(string(out))) + } + + }) +} + +func TestDeleteUptoCheckedOutBranch(t *testing.T) { + t.Run("without force - should error", func(t *testing.T) { + dir := initTestRepo(t) + inDir(t, dir) + run(t, dir, "git", "checkout", "-b", "test/1") + run(t, dir, "git", "checkout", "-b", "test/2") + run(t, dir, "git", "checkout", "-b", "test/3") + run(t, dir, "git", "checkout", "test/1") + + forceFlag = false + err := cmdDeleteUpto([]string{"test", "3"}) + if err == nil { + t.Fatal("expected error when deleting checked-out branch") + } + if !strings.Contains(err.Error(), "currently checked out") { + t.Fatalf("expected 'currently checked out' error, got: %v", err) + } + + // Branches should still exist + branches := branchList(t, dir, "test/*") + if len(branches) != 3 { + t.Fatalf("expected all branches to still exist, got: %v", branches) + } + }) + + t.Run("with force - should detach and delete", func(t *testing.T) { + dir := initTestRepo(t) + inDir(t, dir) + run(t, dir, "git", "checkout", "-b", "test/1") + run(t, dir, "git", "checkout", "-b", "test/2") + run(t, dir, "git", "checkout", "-b", "test/3") + run(t, dir, "git", "checkout", "test/1") + + forceFlag = true + err := cmdDeleteUpto([]string{"test", "3"}) + if err != nil { + t.Fatalf("unexpected error with --force: %v", err) + } + + // test/1 and test/2 should be deleted + branches := branchList(t, dir, "test/*") + if len(branches) != 1 || branches[0] != "test/3" { + t.Fatalf("expected only test/3, got: %v", branches) + } + + forceFlag = false + }) +} + +func TestRenameCheckedOutBranch(t *testing.T) { + t.Run("without force - should error", func(t *testing.T) { + dir := initTestRepo(t) + inDir(t, dir) + run(t, dir, "git", "checkout", "-b", "old/1") + + forceFlag = false + err := cmdRename([]string{"old", "new"}) + if err == nil { + t.Fatal("expected error when renaming checked-out branch") + } + if !strings.Contains(err.Error(), "currently checked out") { + t.Fatalf("expected 'currently checked out' error, got: %v", err) + } + + // Old branch should still exist + old := branchList(t, dir, "old/*") + if len(old) != 1 { + t.Fatalf("expected old branch to still exist, got: %v", old) + } + }) + + t.Run("with force - should detach and rename", func(t *testing.T) { + dir := initTestRepo(t) + inDir(t, dir) + run(t, dir, "git", "checkout", "-b", "old/1") + + forceFlag = true + err := cmdRename([]string{"old", "new"}) + if err != nil { + t.Fatalf("unexpected error with --force: %v", err) + } + + // Old branch should be renamed + old := branchList(t, dir, "old/*") + new := branchList(t, dir, "new/*") + if len(old) != 0 { + t.Fatalf("expected old branch to be renamed, got: %v", old) + } + if len(new) != 1 || new[0] != "new/1" { + t.Fatalf("expected new/1, got: %v", new) + } + + // Should be in detached HEAD state + cmd := exec.Command("git", "symbolic-ref", "--quiet", "--short", "HEAD") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + // Command should fail (exit 1) when in detached HEAD + if err == nil { + t.Fatalf("expected detached HEAD (command should fail), got branch: %v", strings.TrimSpace(string(out))) + } + + forceFlag = false + }) +} + +func TestDeleteBranchInWorktree(t *testing.T) { + dir := initTestRepo(t) + inDir(t, dir) + + run(t, dir, "git", "checkout", "-b", "test/1") + run(t, dir, "git", "checkout", "main") + + // Create worktree + wtDir := dir + "/wt" + run(t, dir, "git", "worktree", "add", wtDir, "test/1") + + // Should error even with --force + forceFlag = true + err := cmdDelete([]string{"test"}) + if err == nil { + t.Fatal("expected error when deleting branch in worktree") + } + if !strings.Contains(err.Error(), "worktree") { + t.Fatalf("expected 'worktree' error, got: %v", err) + } + + // Branch should still exist + branches := branchList(t, dir, "test/*") + if len(branches) != 1 { + t.Fatalf("expected branch to still exist, got: %v", branches) + } + + forceFlag = false +} + +func TestDeleteMultipleBranchesInWorktrees(t *testing.T) { + dir := initTestRepo(t) + inDir(t, dir) + + run(t, dir, "git", "checkout", "-b", "test/1") + run(t, dir, "git", "checkout", "-b", "test/2") + run(t, dir, "git", "checkout", "main") + + // Create worktrees for both + run(t, dir, "git", "worktree", "add", dir+"/wt1", "test/1") + run(t, dir, "git", "worktree", "add", dir+"/wt2", "test/2") + + // Should error and list both branches + forceFlag = true + err := cmdDelete([]string{"test"}) + if err == nil { + t.Fatal("expected error when deleting branches in worktrees") + } + if !strings.Contains(err.Error(), "test/1") || !strings.Contains(err.Error(), "test/2") { + t.Fatalf("expected both branches listed, got: %v", err) + } + + forceFlag = false +} From ad680e808fe7284308263dcea7144ec6b2bb263c Mon Sep 17 00:00:00 2001 From: Clay Bridges Date: Fri, 10 Apr 2026 20:18:19 -0400 Subject: [PATCH 2/5] claude tweaks --- cmd/git-folder/main.go | 58 ++++++++++++++++++------------------- cmd/git-folder/main_test.go | 2 +- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/cmd/git-folder/main.go b/cmd/git-folder/main.go index 596f79c..da04107 100644 --- a/cmd/git-folder/main.go +++ b/cmd/git-folder/main.go @@ -43,17 +43,42 @@ func confirm(prompt string) bool { } func checkBranchesNotCheckedOut(branches []string) error { - // Get current branch + // Get current branch first (needed by both checks) out, _ := exec.Command("git", "symbolic-ref", "--quiet", "--short", "HEAD").Output() current := strings.TrimSpace(string(out)) - var checkedOutInWorktrees []string + // Check worktrees first (fail early before any side effects) + // Skip the current branch here — it's handled by the detach logic below. + wtOut, err := exec.Command("git", "worktree", "list", "--porcelain").Output() + if err == nil { + var checkedOutInWorktrees []string + lines := strings.Split(string(wtOut), "\n") + for _, line := range lines { + if !strings.HasPrefix(line, "branch ") { + continue + } + wtBranch := strings.TrimPrefix(line, "branch refs/heads/") + if wtBranch == current { + continue + } + for _, b := range branches { + if b == wtBranch { + checkedOutInWorktrees = append(checkedOutInWorktrees, b) + } + } + } + if len(checkedOutInWorktrees) > 0 { + if len(checkedOutInWorktrees) == 1 { + return fmt.Errorf("cannot modify branch '%s': checked out in a worktree", checkedOutInWorktrees[0]) + } + return fmt.Errorf("cannot modify branches: checked out in worktrees: %s", strings.Join(checkedOutInWorktrees, ", ")) + } + } // Check if current branch is in list for _, b := range branches { if b == current { if forceFlag { - // Detach HEAD at current commit so we can modify the branch fmt.Printf("Detaching HEAD from '%s' (branch is being modified)\n", b) if err := gitExec("checkout", "--detach"); err != nil { return fmt.Errorf("failed to detach HEAD: %w", err) @@ -64,33 +89,6 @@ func checkBranchesNotCheckedOut(branches []string) error { } } - // Check worktrees - out, err := exec.Command("git", "worktree", "list", "--porcelain").Output() - if err != nil { - // If worktree command fails, continue (no worktrees or git doesn't support it) - return nil - } - - lines := strings.Split(string(out), "\n") - for _, line := range lines { - if !strings.HasPrefix(line, "branch ") { - continue - } - wtBranch := strings.TrimPrefix(line, "branch refs/heads/") - for _, b := range branches { - if b == wtBranch { - checkedOutInWorktrees = append(checkedOutInWorktrees, b) - } - } - } - - if len(checkedOutInWorktrees) > 0 { - if len(checkedOutInWorktrees) == 1 { - return fmt.Errorf("cannot modify branch '%s': checked out in a worktree", checkedOutInWorktrees[0]) - } - return fmt.Errorf("cannot modify branches: checked out in worktrees: %s", strings.Join(checkedOutInWorktrees, ", ")) - } - return nil } diff --git a/cmd/git-folder/main_test.go b/cmd/git-folder/main_test.go index 4a1fd21..846e837 100644 --- a/cmd/git-folder/main_test.go +++ b/cmd/git-folder/main_test.go @@ -908,6 +908,7 @@ func TestDeleteCheckedOutBranch(t *testing.T) { run(t, dir, "git", "checkout", "-b", "test/1") forceFlag = true + defer func() { forceFlag = false }() err := cmdDelete([]string{"test"}) if err != nil { t.Fatalf("unexpected error with --force: %v", err) @@ -927,7 +928,6 @@ func TestDeleteCheckedOutBranch(t *testing.T) { if err == nil { t.Fatalf("expected detached HEAD (command should fail), got branch: %v", strings.TrimSpace(string(out))) } - }) } From 9b51932d6eb63316d3edb5fac4363ec29eb94966 Mon Sep 17 00:00:00 2001 From: Clay Bridges Date: Fri, 10 Apr 2026 20:59:33 -0400 Subject: [PATCH 3/5] Address Copilot review: fix detach-before-conflict bug, defer forceFlag cleanup - In cmdRename, move conflict check before checkBranchesNotCheckedOut so a failed rename doesn't leave the user in detached HEAD state - Use defer to reset forceFlag in tests so it's always cleaned up even on early failure Co-Authored-By: Claude Sonnet 4.6 --- cmd/git-folder/main.go | 12 ++++++------ cmd/git-folder/main_test.go | 12 ++++-------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/cmd/git-folder/main.go b/cmd/git-folder/main.go index da04107..bd2fd34 100644 --- a/cmd/git-folder/main.go +++ b/cmd/git-folder/main.go @@ -352,17 +352,17 @@ func cmdRename(args []string) error { return fmt.Errorf("no branches in folder %s/", oldName) } - // Check if any source branches are checked out - if err := checkBranchesNotCheckedOut(sources); err != nil { - return err - } - - // Check for conflicts + // Check for conflicts before any side effects existing, _ := folder.Enumerate(newName) if len(existing) > 0 { return fmt.Errorf("target folder %s/ already has branches", newName) } + // Check if any source branches are checked out + if err := checkBranchesNotCheckedOut(sources); err != nil { + return err + } + // Build rename pairs type pair struct{ from, to string } pairs := make([]pair, len(sources)) diff --git a/cmd/git-folder/main_test.go b/cmd/git-folder/main_test.go index 846e837..f192b9b 100644 --- a/cmd/git-folder/main_test.go +++ b/cmd/git-folder/main_test.go @@ -965,6 +965,7 @@ func TestDeleteUptoCheckedOutBranch(t *testing.T) { run(t, dir, "git", "checkout", "test/1") forceFlag = true + defer func() { forceFlag = false }() err := cmdDeleteUpto([]string{"test", "3"}) if err != nil { t.Fatalf("unexpected error with --force: %v", err) @@ -975,8 +976,6 @@ func TestDeleteUptoCheckedOutBranch(t *testing.T) { if len(branches) != 1 || branches[0] != "test/3" { t.Fatalf("expected only test/3, got: %v", branches) } - - forceFlag = false }) } @@ -1008,6 +1007,7 @@ func TestRenameCheckedOutBranch(t *testing.T) { run(t, dir, "git", "checkout", "-b", "old/1") forceFlag = true + defer func() { forceFlag = false }() err := cmdRename([]string{"old", "new"}) if err != nil { t.Fatalf("unexpected error with --force: %v", err) @@ -1031,8 +1031,6 @@ func TestRenameCheckedOutBranch(t *testing.T) { if err == nil { t.Fatalf("expected detached HEAD (command should fail), got branch: %v", strings.TrimSpace(string(out))) } - - forceFlag = false }) } @@ -1049,6 +1047,7 @@ func TestDeleteBranchInWorktree(t *testing.T) { // Should error even with --force forceFlag = true + defer func() { forceFlag = false }() err := cmdDelete([]string{"test"}) if err == nil { t.Fatal("expected error when deleting branch in worktree") @@ -1062,8 +1061,6 @@ func TestDeleteBranchInWorktree(t *testing.T) { if len(branches) != 1 { t.Fatalf("expected branch to still exist, got: %v", branches) } - - forceFlag = false } func TestDeleteMultipleBranchesInWorktrees(t *testing.T) { @@ -1080,6 +1077,7 @@ func TestDeleteMultipleBranchesInWorktrees(t *testing.T) { // Should error and list both branches forceFlag = true + defer func() { forceFlag = false }() err := cmdDelete([]string{"test"}) if err == nil { t.Fatal("expected error when deleting branches in worktrees") @@ -1087,6 +1085,4 @@ func TestDeleteMultipleBranchesInWorktrees(t *testing.T) { if !strings.Contains(err.Error(), "test/1") || !strings.Contains(err.Error(), "test/2") { t.Fatalf("expected both branches listed, got: %v", err) } - - forceFlag = false } From 53624d3bf72a3b848a453b135fb8e2ec1f44a915 Mon Sep 17 00:00:00 2001 From: Clay Bridges Date: Fri, 10 Apr 2026 21:32:27 -0400 Subject: [PATCH 4/5] Split checkBranchesNotCheckedOut into pure validation + late detach branchesPreflight() is now side-effect-free: it checks worktrees and the current branch, returning the branch to detach (or "") without touching repo state. Callers detach HEAD immediately before the first mutating operation, after all validation and user confirmation. Also: fail closed on git symbolic-ref and git worktree list errors instead of silently proceeding with empty/stale state. Co-Authored-By: Claude Sonnet 4.6 --- cmd/git-folder/main.go | 108 ++++++++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 39 deletions(-) diff --git a/cmd/git-folder/main.go b/cmd/git-folder/main.go index bd2fd34..e49a357 100644 --- a/cmd/git-folder/main.go +++ b/cmd/git-folder/main.go @@ -3,6 +3,7 @@ package main import ( "bufio" _ "embed" + "errors" "fmt" "math" "os" @@ -42,53 +43,67 @@ func confirm(prompt string) bool { return answer == "y" } -func checkBranchesNotCheckedOut(branches []string) error { - // Get current branch first (needed by both checks) - out, _ := exec.Command("git", "symbolic-ref", "--quiet", "--short", "HEAD").Output() +// branchesPreflight validates that the given branches can be modified. +// It returns the name of the current branch if it needs to be detached +// (i.e. --force is set and the current branch is in the list), or "" if not. +// It fails closed: errors from git symbolic-ref and git worktree list are returned. +func branchesPreflight(branches []string) (detachBranch string, err error) { + // Get current branch; exit code 1 = detached HEAD (ok), anything else = error. + out, symErr := exec.Command("git", "symbolic-ref", "--quiet", "--short", "HEAD").Output() current := strings.TrimSpace(string(out)) + if symErr != nil { + var exitErr *exec.ExitError + if errors.As(symErr, &exitErr) && exitErr.ExitCode() == 1 { + current = "" // detached HEAD + } else { + return "", fmt.Errorf("failed to determine current branch: %w", symErr) + } + } - // Check worktrees first (fail early before any side effects) - // Skip the current branch here — it's handled by the detach logic below. - wtOut, err := exec.Command("git", "worktree", "list", "--porcelain").Output() - if err == nil { - var checkedOutInWorktrees []string - lines := strings.Split(string(wtOut), "\n") - for _, line := range lines { - if !strings.HasPrefix(line, "branch ") { - continue - } - wtBranch := strings.TrimPrefix(line, "branch refs/heads/") - if wtBranch == current { - continue - } - for _, b := range branches { - if b == wtBranch { - checkedOutInWorktrees = append(checkedOutInWorktrees, b) - } - } + // Check worktrees — fail closed if this command fails. + wtOut, wtErr := exec.Command("git", "worktree", "list", "--porcelain").Output() + if wtErr != nil { + return "", fmt.Errorf("failed to check git worktrees: %w", wtErr) + } + var inWorktrees []string + for _, line := range strings.Split(string(wtOut), "\n") { + if !strings.HasPrefix(line, "branch ") { + continue + } + wtBranch := strings.TrimPrefix(line, "branch refs/heads/") + if wtBranch == current { + continue // handled below } - if len(checkedOutInWorktrees) > 0 { - if len(checkedOutInWorktrees) == 1 { - return fmt.Errorf("cannot modify branch '%s': checked out in a worktree", checkedOutInWorktrees[0]) + for _, b := range branches { + if b == wtBranch { + inWorktrees = append(inWorktrees, b) } - return fmt.Errorf("cannot modify branches: checked out in worktrees: %s", strings.Join(checkedOutInWorktrees, ", ")) } } + if len(inWorktrees) == 1 { + return "", fmt.Errorf("cannot modify branch '%s': checked out in a worktree", inWorktrees[0]) + } else if len(inWorktrees) > 1 { + return "", fmt.Errorf("cannot modify branches: checked out in worktrees: %s", strings.Join(inWorktrees, ", ")) + } - // Check if current branch is in list + // Check if current branch is in list. for _, b := range branches { if b == current { if forceFlag { - fmt.Printf("Detaching HEAD from '%s' (branch is being modified)\n", b) - if err := gitExec("checkout", "--detach"); err != nil { - return fmt.Errorf("failed to detach HEAD: %w", err) - } - } else { - return fmt.Errorf("cannot modify branch '%s': currently checked out (use --force to detach)", b) + return current, nil // caller will detach before first mutation } + return "", fmt.Errorf("cannot modify branch '%s': currently checked out (use --force to detach)", b) } } + return "", nil +} +// detachHEAD detaches HEAD from the named branch, printing a message. +func detachHEAD(branch string) error { + fmt.Printf("Detaching HEAD from '%s' (branch is being modified)\n", branch) + if err := gitExec("checkout", "--detach"); err != nil { + return fmt.Errorf("failed to detach HEAD: %w", err) + } return nil } @@ -255,8 +270,8 @@ func cmdDelete(args []string) error { return fmt.Errorf("no branches in folder %s/", name) } - // Check if any branches are checked out - if err := checkBranchesNotCheckedOut(branches); err != nil { + detach, err := branchesPreflight(branches) + if err != nil { return err } @@ -270,6 +285,11 @@ func cmdDelete(args []string) error { return nil } + if detach != "" { + if err := detachHEAD(detach); err != nil { + return err + } + } for _, b := range branches { if err := gitExec("branch", "-D", b); err != nil { return fmt.Errorf("failed to delete %s: %w", b, err) @@ -309,8 +329,8 @@ func cmdDeleteUpto(args []string) error { return fmt.Errorf("no numbered branches below %v in folder %s/", n, folderName) } - // Check if any branches to delete are checked out - if err := checkBranchesNotCheckedOut(toDelete); err != nil { + detach, err := branchesPreflight(toDelete) + if err != nil { return err } @@ -329,6 +349,11 @@ func cmdDeleteUpto(args []string) error { return nil } + if detach != "" { + if err := detachHEAD(detach); err != nil { + return err + } + } for _, b := range toDelete { if err := gitExec("branch", "-D", b); err != nil { return fmt.Errorf("failed to delete %s: %w", b, err) @@ -358,8 +383,8 @@ func cmdRename(args []string) error { return fmt.Errorf("target folder %s/ already has branches", newName) } - // Check if any source branches are checked out - if err := checkBranchesNotCheckedOut(sources); err != nil { + detach, err := branchesPreflight(sources) + if err != nil { return err } @@ -381,6 +406,11 @@ func cmdRename(args []string) error { return nil } + if detach != "" { + if err := detachHEAD(detach); err != nil { + return err + } + } for _, p := range pairs { if err := gitExec("branch", "-m", p.from, p.to); err != nil { return fmt.Errorf("failed to rename %s -> %s: %w", p.from, p.to, err) From 976f56ace656d259673da45634cd3a3df543f294 Mon Sep 17 00:00:00 2001 From: Clay Bridges Date: Fri, 10 Apr 2026 21:44:34 -0400 Subject: [PATCH 5/5] . --- cmd/git-folder/main.go | 5 ++++- cmd/git-folder/main_test.go | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/cmd/git-folder/main.go b/cmd/git-folder/main.go index e49a357..230f2c6 100644 --- a/cmd/git-folder/main.go +++ b/cmd/git-folder/main.go @@ -378,7 +378,10 @@ func cmdRename(args []string) error { } // Check for conflicts before any side effects - existing, _ := folder.Enumerate(newName) + existing, err := folder.Enumerate(newName) + if err != nil { + return err + } if len(existing) > 0 { return fmt.Errorf("target folder %s/ already has branches", newName) } diff --git a/cmd/git-folder/main_test.go b/cmd/git-folder/main_test.go index f192b9b..e4eb28d 100644 --- a/cmd/git-folder/main_test.go +++ b/cmd/git-folder/main_test.go @@ -1042,7 +1042,7 @@ func TestDeleteBranchInWorktree(t *testing.T) { run(t, dir, "git", "checkout", "main") // Create worktree - wtDir := dir + "/wt" + wtDir := filepath.Join(dir, "wt") run(t, dir, "git", "worktree", "add", wtDir, "test/1") // Should error even with --force @@ -1072,8 +1072,8 @@ func TestDeleteMultipleBranchesInWorktrees(t *testing.T) { run(t, dir, "git", "checkout", "main") // Create worktrees for both - run(t, dir, "git", "worktree", "add", dir+"/wt1", "test/1") - run(t, dir, "git", "worktree", "add", dir+"/wt2", "test/2") + run(t, dir, "git", "worktree", "add", filepath.Join(dir, "wt1"), "test/1") + run(t, dir, "git", "worktree", "add", filepath.Join(dir, "wt2"), "test/2") // Should error and list both branches forceFlag = true