diff --git a/cmd/git-folder/main.go b/cmd/git-folder/main.go index 3d3f775..230f2c6 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,6 +43,70 @@ func confirm(prompt string) bool { return answer == "y" } +// 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 — 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 + } + for _, b := range branches { + if b == wtBranch { + inWorktrees = append(inWorktrees, b) + } + } + } + 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. + for _, b := range branches { + if b == current { + if forceFlag { + 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 +} + func main() { if len(os.Args) < 2 { usage() @@ -205,6 +270,11 @@ func cmdDelete(args []string) error { return fmt.Errorf("no branches in folder %s/", name) } + detach, err := branchesPreflight(branches) + if err != nil { + return err + } + fmt.Printf("delete all branches in folder %s/:\n", name) for _, b := range branches { fmt.Printf(" %s\n", b) @@ -215,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) @@ -254,6 +329,11 @@ func cmdDeleteUpto(args []string) error { return fmt.Errorf("no numbered branches below %v in folder %s/", n, folderName) } + detach, err := branchesPreflight(toDelete) + if err != nil { + return err + } + fmt.Printf("keep:\n") for _, b := range toKeep { fmt.Printf(" %s\n", b) @@ -269,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) @@ -292,12 +377,20 @@ func cmdRename(args []string) error { return fmt.Errorf("no branches in folder %s/", oldName) } - // Check for conflicts - existing, _ := folder.Enumerate(newName) + // Check for conflicts before any side effects + existing, err := folder.Enumerate(newName) + if err != nil { + return err + } if len(existing) > 0 { return fmt.Errorf("target folder %s/ already has branches", newName) } + detach, err := branchesPreflight(sources) + if err != nil { + return err + } + // Build rename pairs type pair struct{ from, to string } pairs := make([]pair, len(sources)) @@ -316,6 +409,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) diff --git a/cmd/git-folder/main_test.go b/cmd/git-folder/main_test.go index 8b78072..e4eb28d 100644 --- a/cmd/git-folder/main_test.go +++ b/cmd/git-folder/main_test.go @@ -877,3 +877,212 @@ 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 + defer func() { forceFlag = false }() + 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 + defer func() { forceFlag = false }() + 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) + } + }) +} + +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 + defer func() { forceFlag = false }() + 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))) + } + }) +} + +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 := filepath.Join(dir, "wt") + run(t, dir, "git", "worktree", "add", wtDir, "test/1") + + // 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") + } + 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) + } +} + +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", 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 + defer func() { forceFlag = false }() + 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) + } +}