Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions cmd/git-folder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"bufio"
_ "embed"
"errors"
"fmt"
"math"
"os"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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))
Expand All @@ -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)
Expand Down
209 changes: 209 additions & 0 deletions cmd/git-folder/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
claybridges marked this conversation as resolved.

// 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)
}
Comment thread
claybridges marked this conversation as resolved.

// 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")

Comment thread
claybridges marked this conversation as resolved.
// 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")
}
Comment thread
claybridges marked this conversation as resolved.
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")
}
Comment thread
claybridges marked this conversation as resolved.
if !strings.Contains(err.Error(), "test/1") || !strings.Contains(err.Error(), "test/2") {
t.Fatalf("expected both branches listed, got: %v", err)
}
}