From 1f7962630f63d0e9f5df2da2dc68c114c1df9e35 Mon Sep 17 00:00:00 2001 From: EdmondDantes21 Date: Thu, 15 Oct 2026 14:13:40 +0200 Subject: [PATCH] fix: only treat a missing branch as a new branch on clone Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/clients/git/git.go | 6 +++- internal/clients/git/git_test.go | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/internal/clients/git/git.go b/internal/clients/git/git.go index 29135fa..633f351 100644 --- a/internal/clients/git/git.go +++ b/internal/clients/git/git.go @@ -42,6 +42,7 @@ var ( ErrEmptyRemoteRepository = errors.New("remote repository is empty") ErrAuthenticationRequired = errors.New("authentication required") ErrAuthorizationFailed = errors.New("authorization failed") + ErrBranchNotFound = errors.New("branch not found") NoErrAlreadyUpToDate = git.NoErrAlreadyUpToDate ) @@ -191,7 +192,7 @@ func GetLatestCommitRemote(opts ListOptions) (*string, error) { } } - return nil, fmt.Errorf("Branch %s reference %s not found on remote %s", opts.Branch, repoRef, opts.URL) + return nil, fmt.Errorf("%w: branch %s reference %s not found on remote %s", ErrBranchNotFound, opts.Branch, repoRef, opts.URL) } func restoreUnsupportedCapabilities(oldUnsupportedCaps []capability.Capability) { @@ -390,6 +391,9 @@ func Clone(opts CloneOptions) (*Repo, error) { GitCookies: opts.GitCookies, }) if err != nil { + if !errors.Is(err, ErrBranchNotFound) { + return nil, fmt.Errorf("failed to inspect remote branch: %w", err) + } cloneOpts = git.CloneOptions{ RemoteName: "origin", URL: opts.URL, diff --git a/internal/clients/git/git_test.go b/internal/clients/git/git_test.go index f314baa..e816dfc 100644 --- a/internal/clients/git/git_test.go +++ b/internal/clients/git/git_test.go @@ -1,8 +1,16 @@ package git import ( + "errors" + "net/http" + "net/http/cgi" + "net/http/httptest" "os" + "os/exec" "path" + "path/filepath" + "strings" + "sync/atomic" "testing" "github.com/go-git/go-git/v5" @@ -361,3 +369,55 @@ func TestCommitPreservesFileMode(t *testing.T) { assert.Equal(t, filemode.Executable, entry.Mode, name) } } + +func TestGetLatestCommitRemoteBranchNotFound(t *testing.T) { + baseRepo := BaseSuite{} + baseRepo.BuildBasicRepository() + + _, err := GetLatestCommitRemote(ListOptions{ + URL: baseRepo.GetBasicLocalRepositoryURL(), + Branch: "missing-branch", + }) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrBranchNotFound)) +} + +// A failed branch lookup that is not "branch not found" must not be treated as a new branch: +// that path empties the worktree, so the next commit would delete every existing file. +func TestCloneFailsOnTransientBranchLookupError(t *testing.T) { + gitExecPath, err := exec.Command("git", "--exec-path").Output() + if err != nil { + t.Skip("git not available") + } + + baseRepo := BaseSuite{} + baseRepo.BuildBasicRepository() + dotGit := baseRepo.GetBasicLocalRepositoryURL() + + backend := &cgi.Handler{ + Path: filepath.Join(strings.TrimSpace(string(gitExecPath)), "git-http-backend"), + Env: []string{ + "GIT_PROJECT_ROOT=" + filepath.Dir(dotGit), + "GIT_HTTP_EXPORT_ALL=1", + }, + } + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + backend.ServeHTTP(w, r) + })) + defer srv.Close() + + repo, err := Clone(CloneOptions{ + URL: srv.URL + "/" + filepath.Base(dotGit), + Branch: "master", + }) + if repo != nil { + defer repo.Cleanup() + } + require.Error(t, err) + assert.False(t, errors.Is(err, ErrBranchNotFound)) +}