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
6 changes: 6 additions & 0 deletions pkg/gitexec/gitexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ func (r *Repo) CreateBranch(branch string) error {
return r.run("git", "checkout", "-b", branch)
}

// CreateBranchFrom creates a new branch based on the given start point (e.g.
// another branch or commit) and switches to it.
func (r *Repo) CreateBranchFrom(branch string, startPoint string) error {
return r.run("git", "checkout", "-b", branch, startPoint)
}

func (r *Repo) DeleteBranch(branch string) error {
return xexec.Command("git", "branch", "-D", branch).
WithEnvVars(CleanedGitEnv()).
Expand Down
88 changes: 75 additions & 13 deletions pkg/yas/yas.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,42 +130,104 @@ func (yas *YAS) CreateBranch(branchName string, parentBranch string) (string, er
fullBranchName = fmt.Sprintf("%s/%s", username, branchName)
}

// Determine parent branch
// Determine the current branch so we can decide where to base the new
// branch from. When no parent is given we require it (it becomes the
// parent). When an explicit parent is given we don't strictly need it, so
// we ignore errors here (e.g. detached HEAD): it's only used to decide
// whether to branch from the parent rather than the current HEAD.
var currentBranch string

if parentBranch == "" {
// Use current branch as parent
currentBranch, err := yas.git.GetCurrentBranchName()
var err error

currentBranch, err = yas.git.GetCurrentBranchName()
if err != nil {
return "", fmt.Errorf("failed to get current branch: %w", err)
}

// Use current branch as parent
parentBranch = currentBranch
} else {
currentBranch, _ = yas.git.GetCurrentBranchName()
}

// When an explicit parent different from the current branch is requested,
// create a fresh branch based on that parent rather than the current HEAD.
// In that case we do not auto-commit; any staged changes are preserved in
// the index (git only carries them across when they don't conflict with
// the switch) rather than being silently discarded.
createFromParent := parentBranch != currentBranch

// Create the new branch
if err := yas.git.CreateBranch(fullBranchName); err != nil {
return "", fmt.Errorf("failed to create branch: %w", err)
if createFromParent {
// Resolve the parent to a ref git can use as a start point. When the
// parent only exists remotely (e.g. origin/main with no local main),
// fall back to the remote-tracking ref.
startPoint, err := yas.resolveBranchStartPoint(parentBranch)
if err != nil {
return "", err
}

if err := yas.git.CreateBranchFrom(fullBranchName, startPoint); err != nil {
return "", fmt.Errorf("failed to create branch: %w", err)
}
} else {
if err := yas.git.CreateBranch(fullBranchName); err != nil {
return "", fmt.Errorf("failed to create branch: %w", err)
}
}

// Add to stack with parent
if err := yas.SetParent(fullBranchName, parentBranch, ""); err != nil {
return "", err
}

// Check for staged changes and commit automatically
hasStagedChanges, err := yas.git.HasStagedChanges()
if err != nil {
return "", fmt.Errorf("failed to check for staged changes: %w", err)
}
// Check for staged changes and commit automatically. This is skipped when
// the branch was created from a different parent, since in that case we
// want a fresh branch and leave any staged changes untouched.
if !createFromParent {
hasStagedChanges, err := yas.git.HasStagedChanges()
if err != nil {
return "", fmt.Errorf("failed to check for staged changes: %w", err)
}

if hasStagedChanges {
if err := yas.git.Commit(); err != nil {
return "", fmt.Errorf("failed to commit staged changes: %w", err)
if hasStagedChanges {
if err := yas.git.Commit(); err != nil {
return "", fmt.Errorf("failed to commit staged changes: %w", err)
}
}
}

return fullBranchName, nil
}

// resolveBranchStartPoint resolves a parent branch name to a git ref usable as
// the start point for a new branch. It prefers a local branch, then falls back
// to the remote-tracking ref (origin/<branch>) for parents that only exist
// remotely. If neither exists it returns the name unchanged so git can surface
// a clear error.
func (yas *YAS) resolveBranchStartPoint(parentBranch string) (string, error) {
localExists, err := yas.git.BranchExists(parentBranch)
if err != nil {
return "", fmt.Errorf("failed to check if parent branch exists: %w", err)
}

if localExists {
return parentBranch, nil
}

remoteExists, err := yas.git.RemoteBranchExists(parentBranch)
if err != nil {
return "", fmt.Errorf("failed to check if parent branch exists remotely: %w", err)
}

if remoteExists {
return "origin/" + parentBranch, nil
}

return parentBranch, nil
}

func (yas *YAS) Reload() error {
return yas.data.Reload()
}
157 changes: 157 additions & 0 deletions test/branch_create_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package test

import (
"strings"
"testing"

"github.com/dansimau/yas/pkg/gocmdtester"
Expand Down Expand Up @@ -113,6 +114,162 @@ func TestBranchCreate_WithParentAndPrefix(t *testing.T) {
assert.Equal(t, exitCode, 0, "Expected branch testuser/feature-b to exist")
}

func TestBranchCreate_WithDifferentParentBranchesFromParent(t *testing.T) {
t.Parallel()

tempDir := t.TempDir()

cli := gocmdtester.FromPath(t, "../cmd/yas/main.go",
gocmdtester.WithWorkingDir(tempDir),
gocmdtester.WithEnv("GIT_AUTHOR_EMAIL", "testuser@example.com"),
)

testutil.ExecOrFail(t, tempDir, `
git init --initial-branch=main
touch main
git add main
git commit -m "main-0"

git checkout -b feature-a
touch a
git add a
git commit -m "feature-a-0"
`)

// Initialize yas with auto-prefix disabled to keep branch names simple.
assert.NilError(t, cli.Run("config", "set", "--trunk-branch=main").Err())
assert.NilError(t, cli.Run("config", "set", "--no-auto-prefix-branch").Err())
assert.NilError(t, cli.Run("add", "feature-a", "--parent=main").Err())

// We are currently on feature-a (which contains the "a" file). Stage a new
// file that should NOT be carried over to the new branch.
testutil.ExecOrFail(t, tempDir, `
touch staged-file
git add staged-file
`)

// Create a new branch with an explicit parent that differs from the
// current branch. The branch should be created fresh from main.
assert.NilError(t, cli.Run("branch", "--parent=main", "feature-b").Err())

// Verify we switched to the new branch.
currentBranch := mustExecOutput(tempDir, "git", "branch", "--show-current")
equalLines(t, currentBranch, "feature-b")

// The new branch should be based on main, so it must NOT contain the "a"
// file that exists only on feature-a.
exitCode := mustExecExitCode(tempDir, "git", "cat-file", "-e", "HEAD:a")
assert.Assert(t, exitCode != 0, "Expected file 'a' (from feature-a) to NOT exist on feature-b")

// The staged file should not have been committed to the new branch.
exitCode = mustExecExitCode(tempDir, "git", "cat-file", "-e", "HEAD:staged-file")
assert.Assert(t, exitCode != 0, "Expected staged file to NOT be committed onto feature-b")

// HEAD of feature-b should match HEAD of main.
mainRef := strings.TrimSpace(mustExecOutput(tempDir, "git", "rev-parse", "main"))
headRef := strings.TrimSpace(mustExecOutput(tempDir, "git", "rev-parse", "HEAD"))
assert.Equal(t, headRef, mainRef, "Expected feature-b to be based on main's HEAD")
}

func TestBranchCreate_FromRemoteOnlyParent(t *testing.T) {
t.Parallel()

tempDir := t.TempDir()
fakeOrigin := t.TempDir()

cli := gocmdtester.FromPath(t, "../cmd/yas/main.go",
gocmdtester.WithWorkingDir(tempDir),
gocmdtester.WithEnv("GIT_AUTHOR_EMAIL", "testuser@example.com"),
)

testutil.ExecOrFail(t, tempDir, `
# Set up "remote" bare repository
git init --bare `+fakeOrigin+`

git init --initial-branch=main
git remote add origin `+fakeOrigin+`
touch main
git add main
git commit -m "main-0"

# Create a parent branch, push it, then delete it locally so it only
# exists as origin/base.
git checkout -b base
touch base-file
git add base-file
git commit -m "base-0"
git push origin base
git checkout main
git branch -D base
git fetch origin
`)

// Initialize yas before any detach/remote-only state.
assert.NilError(t, cli.Run("config", "set", "--trunk-branch=main").Err())
assert.NilError(t, cli.Run("config", "set", "--no-auto-prefix-branch").Err())

// Sanity: the parent does not exist locally, only as origin/base.
localBase := mustExecOutput(tempDir, "git", "branch", "--list", "base")
equalLines(t, localBase, "")

// Create a new branch from the remote-only parent.
assert.NilError(t, cli.Run("branch", "--parent=base", "feature").Err())

// Verify we switched to the new branch.
currentBranch := mustExecOutput(tempDir, "git", "branch", "--show-current")
equalLines(t, currentBranch, "feature")

// The new branch should be based on origin/base's HEAD.
originBaseRef := strings.TrimSpace(mustExecOutput(tempDir, "git", "rev-parse", "origin/base"))
headRef := strings.TrimSpace(mustExecOutput(tempDir, "git", "rev-parse", "HEAD"))
assert.Equal(t, headRef, originBaseRef, "Expected feature to be based on origin/base")
}

func TestBranchCreate_DetachedHeadWithExplicitParent(t *testing.T) {
t.Parallel()

tempDir := t.TempDir()

cli := gocmdtester.FromPath(t, "../cmd/yas/main.go",
gocmdtester.WithWorkingDir(tempDir),
gocmdtester.WithEnv("GIT_AUTHOR_EMAIL", "testuser@example.com"),
)

testutil.ExecOrFail(t, tempDir, `
git init --initial-branch=main
touch main
git add main
git commit -m "main-0"
touch main2
git add main2
git commit -m "main-1"
`)

// Initialize yas while still on a branch.
assert.NilError(t, cli.Run("config", "set", "--trunk-branch=main").Err())
assert.NilError(t, cli.Run("config", "set", "--no-auto-prefix-branch").Err())

// Detach HEAD.
testutil.ExecOrFail(t, tempDir, `git checkout --detach HEAD~1`)

// Sanity: we are in detached HEAD (no current branch).
currentBranch := mustExecOutput(tempDir, "git", "branch", "--show-current")
equalLines(t, currentBranch, "")

// Creating a branch with an explicit parent should work even in detached
// HEAD, since the current branch name is not required.
assert.NilError(t, cli.Run("branch", "--parent=main", "feature").Err())

// Verify we are now on the new branch.
currentBranch = mustExecOutput(tempDir, "git", "branch", "--show-current")
equalLines(t, currentBranch, "feature")

// The new branch should be based on main's HEAD.
mainRef := strings.TrimSpace(mustExecOutput(tempDir, "git", "rev-parse", "main"))
headRef := strings.TrimSpace(mustExecOutput(tempDir, "git", "rev-parse", "HEAD"))
assert.Equal(t, headRef, mainRef, "Expected feature to be based on main's HEAD")
}

func TestBranchCreate_ExtractUsernameFromEmail(t *testing.T) {
t.Parallel()

Expand Down
Loading