Skip to content
Open
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
68 changes: 68 additions & 0 deletions cmd/entire/cli/git_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import (

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/storer"
"golang.org/x/text/unicode/norm"
)

// The fetch output is deliberately not spliced in here: remote.Fetch already
Expand Down Expand Up @@ -289,6 +291,72 @@ func BranchExistsLocally(ctx context.Context, branchName string) (bool, error) {
return true, nil
}

// branchNamesEquivalent reports whether two branch names denote the same branch
// under Unicode normalization. Git normalizes ref names (core.precomposeUnicode
// on macOS/Windows) and treats a precomposed (NFC) and decomposed (NFD) spelling
// of the same accented name as one branch; a byte-exact comparison does not, so
// entire would otherwise disagree with git about branch identity. Folding both
// sides to NFC restores agreement. The ASCII fast-path keeps the common case free.
func branchNamesEquivalent(a, b string) bool {
if a == b {
return true
}
return norm.NFC.String(a) == norm.NFC.String(b)
}

// ResolveLocalBranchName resolves branchName to the name of the local branch it
// denotes, returning the canonical on-disk ref name and whether it was found. An
// exact ref match wins; otherwise it matches Unicode-normalization-insensitively
// (NFC vs NFD) against the existing local branches, so a name whose normalization
// differs from the stored ref — e.g. an NFD branch synced from Linux resolved on
// a macOS checkout, especially once refs are packed and go-git's byte-exact
// packed-refs lookup no longer matches — still resolves, matching git's own
// precomposeUnicode branch identity. The returned canonical name is what callers
// must hand to CheckoutBranch, since that is the name the ref is stored under.
func ResolveLocalBranchName(ctx context.Context, branchName string) (string, bool, error) {
if err := ValidateBranchName(ctx, branchName); err != nil {
return "", false, err
}

repo, err := openRepository(ctx)
if err != nil {
return "", false, fmt.Errorf("failed to open git repository: %w", err)
}
defer repo.Close()

// Fast path: an exact ref exists (loose or packed, byte-identical name).
if _, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true); err == nil {
return branchName, true, nil
} else if !errors.Is(err, plumbing.ErrReferenceNotFound) {
return "", false, fmt.Errorf("failed to check branch: %w", err)
}

// Fallback: normalization-insensitive scan of existing local branches.
want := norm.NFC.String(branchName)
iter, err := repo.Branches()
if err != nil {
return "", false, fmt.Errorf("failed to list branches: %w", err)
}
defer iter.Close()

canonical := ""
err = iter.ForEach(func(ref *plumbing.Reference) error {
name := ref.Name().Short()
if norm.NFC.String(name) == want {
canonical = name
return storer.ErrStop
}
return nil
})
if err != nil {
return "", false, fmt.Errorf("failed to scan local branches: %w", err)
}
if canonical != "" {
return canonical, true, nil
}
return "", false, nil
}

// CheckoutBranch switches to the specified local branch.
// Uses git CLI instead of go-git to work around go-git v5 bug where Checkout
// deletes untracked files (see https://github.com/go-git/go-git/issues/970).
Expand Down
64 changes: 64 additions & 0 deletions cmd/entire/cli/git_operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -984,3 +984,67 @@ func TestFetchBlobsByHash_ChainBudgetBoundsTheWholeOperation(t *testing.T) {
assert.Less(t, elapsed, loopWorstCase,
"a ceiling at or above the loop's worst case (%s) cannot bind — that was the original defect", loopWorstCase)
}

// TestResolveLocalBranchName_UnicodeNormalizationInsensitive covers cli #2551:
// a branch whose on-disk ref differs from the supplied name only by Unicode
// normalization (NFC "é" U+00E9 vs NFD "e"+U+0301) must still resolve, since git
// treats the two spellings as the same branch (core.precomposeUnicode) while a
// byte-exact go-git lookup does not — especially once refs are packed, where the
// packed-refs match is byte-exact on every platform.
func TestResolveLocalBranchName_UnicodeNormalizationInsensitive(t *testing.T) {
testutil.IsolateGitConfigEnv(t)
const (
nfc = "feature/café" // precomposed é
nfd = "feature/café" // decomposed e + combining acute
)
require.NotEqual(t, nfc, nfd, "the two spellings must differ byte-for-byte for this test to mean anything")

dir := t.TempDir()
testutil.InitRepo(t, dir)
// Store refs exactly as given, the way non-precomposing platforms (Linux) do,
// so the branch lands on disk in NFD regardless of the host running the test.
gitRun(t, dir, "config", "core.precomposeUnicode", "false")
testutil.WriteFile(t, dir, "f.txt", "init")
testutil.GitAdd(t, dir, "f.txt")
testutil.GitCommit(t, dir, "init")
defaultBranch := gitDefaultBranch(t, dir)
gitRun(t, dir, "checkout", "-b", nfd)
gitRun(t, dir, "checkout", defaultBranch)
// Pack the refs: go-git matches packed-refs byte-exact, so the NFD ref is no
// longer resolvable by its NFC spelling via an exact lookup — the regression.
gitRun(t, dir, "pack-refs", "--all")
t.Chdir(dir)

ctx := context.Background()

// Exact spelling still resolves via the fast path.
got, found, err := ResolveLocalBranchName(ctx, nfd)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, nfd, got)

// The NFC spelling of the same branch resolves to the canonical on-disk (NFD)
// ref name, rather than being reported as a nonexistent branch.
got, found, err = ResolveLocalBranchName(ctx, nfc)
require.NoError(t, err)
require.True(t, found, "NFC spelling of an NFD-stored branch must resolve (cli #2551)")
require.Equal(t, nfd, got, "must return the on-disk ref name so the checkout targets the real branch")

// A genuinely different name still does not resolve.
_, found, err = ResolveLocalBranchName(ctx, "feature/other")
require.NoError(t, err)
require.False(t, found)
}

func TestBranchNamesEquivalent_NFCvsNFD(t *testing.T) {
t.Parallel()
const (
nfc = "feature/café"
nfd = "feature/café"
)
require.NotEqual(t, nfc, nfd, "the two spellings must differ byte-for-byte")
assert.True(t, branchNamesEquivalent(nfc, nfd), "NFC and NFD spellings denote the same branch")
assert.True(t, branchNamesEquivalent(nfd, nfc), "comparison is symmetric")
assert.True(t, branchNamesEquivalent("main", "main"), "identical ASCII names are equivalent")
assert.False(t, branchNamesEquivalent("feature/a", "feature/b"), "distinct names are not equivalent")
}
54 changes: 31 additions & 23 deletions cmd/entire/cli/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,79 +85,87 @@ func runResume(ctx context.Context, cmd *cobra.Command, branchName string, force
w := cmd.OutOrStdout()
errW := cmd.ErrOrStderr()

proceed, err := switchToBranchForResume(ctx, w, errW, branchName, force)
proceed, resolvedBranch, err := switchToBranchForResume(ctx, w, errW, branchName, force)
if err != nil || !proceed {
return err
}

return resumeFromCurrentBranch(ctx, w, errW, branchName, force)
return resumeFromCurrentBranch(ctx, w, errW, resolvedBranch, force)
}

// switchToBranchForResume ensures the working tree is on branchName, checking it
// out (or fetching it from origin) as needed. It returns proceed=false with a nil
// error when the user declined to fetch a remote-only branch, so callers should
// stop without treating that as a failure.
func switchToBranchForResume(ctx context.Context, w, errW io.Writer, branchName string, force bool) (bool, error) {
// Check if we're already on this branch
//
// It returns the canonical local branch name that callers should use for the
// rest of the resume: a name supplied to `entire resume` (or carried on a synced
// session state) can differ from the on-disk ref only by Unicode normalization
// (NFC vs NFD), which git treats as the same branch but entire's byte-exact
// lookups do not. Resolving to the stored ref name here keeps the checkout and
// the downstream checkpoint lookup pointed at the real branch.
func switchToBranchForResume(ctx context.Context, w, errW io.Writer, branchName string, force bool) (bool, string, error) {
// Check if we're already on this branch (normalization-insensitively, so
// standing on an NFD-named branch and resuming its NFC spelling still counts).
currentBranch, err := GetCurrentBranch(ctx)
if err == nil && currentBranch == branchName {
return true, nil
if err == nil && branchNamesEquivalent(currentBranch, branchName) {
return true, currentBranch, nil
}

// Check if branch exists locally
exists, err := BranchExistsLocally(ctx, branchName)
// Check if the branch exists locally, resolving to its canonical ref name.
resolvedBranch, exists, err := ResolveLocalBranchName(ctx, branchName)
if err != nil {
return false, fmt.Errorf("failed to check branch: %w", err)
return false, "", fmt.Errorf("failed to check branch: %w", err)
}

if !exists {
// Branch doesn't exist locally, check if it exists on remote
remoteExists, err := BranchExistsOnRemote(ctx, branchName)
if err != nil {
return false, fmt.Errorf("failed to check remote branch: %w", err)
return false, "", fmt.Errorf("failed to check remote branch: %w", err)
}

if !remoteExists {
return false, fmt.Errorf("branch '%s' not found locally or on origin", branchName)
return false, "", fmt.Errorf("branch '%s' not found locally or on origin", branchName)
}

// Ask user if they want to fetch from remote (--force skips the prompt)
if !force {
shouldFetch, err := promptFetchFromRemote(branchName)
if err != nil {
return false, err
return false, "", err
}
if !shouldFetch {
return false, nil
return false, "", nil
}
}

// Fetch and checkout the remote branch
fmt.Fprintf(w, "Fetching branch '%s' from origin...\n", branchName)
if err := FetchAndCheckoutRemoteBranch(ctx, branchName); err != nil {
fmt.Fprintf(errW, "Error: failed to checkout branch: %v\n", err)
return false, NewSilentError(errors.New("failed to checkout branch"))
return false, "", NewSilentError(errors.New("failed to checkout branch"))
}
fmt.Fprintf(w, "✓ Switched to branch %s\n", branchName)
return true, nil
return true, branchName, nil
}

// Branch exists locally, check for uncommitted changes before checkout
hasChanges, err := HasUncommittedChanges(ctx)
if err != nil {
return false, fmt.Errorf("failed to check for uncommitted changes: %w", err)
return false, "", fmt.Errorf("failed to check for uncommitted changes: %w", err)
}
if hasChanges {
return false, errors.New("you have uncommitted changes. Please commit or stash them first")
return false, "", errors.New("you have uncommitted changes. Please commit or stash them first")
}

// Checkout the branch
if err := CheckoutBranch(ctx, branchName); err != nil {
// Checkout the branch under its canonical (on-disk) name.
if err := CheckoutBranch(ctx, resolvedBranch); err != nil {
fmt.Fprintf(errW, "Error: failed to checkout branch: %v\n", err)
return false, NewSilentError(errors.New("failed to checkout branch"))
return false, "", NewSilentError(errors.New("failed to checkout branch"))
}
fmt.Fprintf(w, "✓ Switched to branch %s\n", branchName)
return true, nil
fmt.Fprintf(w, "✓ Switched to branch %s\n", resolvedBranch)
return true, resolvedBranch, nil
}

// resumeSessionOnBranch switches to branchName and resumes the specific session
Expand All @@ -168,7 +176,7 @@ func resumeSessionOnBranch(ctx context.Context, cmd *cobra.Command, branchName s
w := cmd.OutOrStdout()
errW := cmd.ErrOrStderr()

proceed, err := switchToBranchForResume(ctx, w, errW, branchName, force)
proceed, _, err := switchToBranchForResume(ctx, w, errW, branchName, force)
if err != nil || !proceed {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/trail_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1859,7 +1859,7 @@ func checkoutTrailBranch(ctx context.Context, w, errW io.Writer, found *api.Trai
return nil
}
fmt.Fprintf(w, "Checking out %s\n", describeTrailRef(found))
proceed, err := switchToBranchForResume(ctx, w, errW, branch, opts.Force)
proceed, _, err := switchToBranchForResume(ctx, w, errW, branch, opts.Force)
if err != nil {
return err
}
Expand Down
6 changes: 3 additions & 3 deletions cmd/entire/cli/trail_resume_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,11 +333,11 @@ func resumeTrailLatest(ctx context.Context, cmd *cobra.Command, branch string, f
if !ensureTrailResumeBranchAvailable(ctx, w, branch) {
return nil
}
proceed, err := switchToBranchForResume(ctx, w, errW, branch, trailResumeSkipBranchPrompts(force))
proceed, resolvedBranch, err := switchToBranchForResume(ctx, w, errW, branch, trailResumeSkipBranchPrompts(force))
if err != nil || !proceed {
return err
}
sessions, err := restoreFromCurrentBranch(ctx, w, errW, branch, force)
sessions, err := restoreFromCurrentBranch(ctx, w, errW, resolvedBranch, force)
if err != nil {
return err
}
Expand All @@ -351,7 +351,7 @@ func resumeTrailCheckpoint(ctx context.Context, cmd *cobra.Command, branch strin
if !ensureTrailResumeBranchAvailable(ctx, w, branch) {
return nil
}
proceed, err := switchToBranchForResume(ctx, w, errW, branch, trailResumeSkipBranchPrompts(force))
proceed, _, err := switchToBranchForResume(ctx, w, errW, branch, trailResumeSkipBranchPrompts(force))
if err != nil || !proceed {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ require (
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/text v0.42.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -146,6 +147,5 @@ require (
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect
golang.org/x/text v0.42.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)