From c95e1e1e92260def44e8f0f672b456eed98694b4 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:19:22 -0400 Subject: [PATCH 1/3] feat(git): change a connected repository's production branch Add 'volcano git production-branch ' and '--reset'. Naming a branch pins it, so a later change to the repository's default branch leaves it alone; --reset makes the project follow the default branch again and says which branch that is. Like root-directory, it works with a project access token and reports what a push to the production branch deploys. Vendors the production-branch spec from Kong/volcano-hosting#2184. --- docs/git.md | 53 +++++-- internal/api/git.go | 13 ++ internal/apiclient/client.gen.go | 2 +- internal/cmd/git/git.go | 16 ++ internal/cmd/git/helpers_test.go | 40 ++++- internal/cmd/git/production_branch.go | 107 +++++++++++++ internal/cmd/git/production_branch_test.go | 176 +++++++++++++++++++++ internal/cmd/git/root_directory.go | 8 +- internal/gitconnect/gitconnect.go | 22 +++ internal/output/git.go | 20 +++ openapi/openapi.yaml | 19 ++- tests/e2e/localmode/git_test.go | 27 ++-- 12 files changed, 469 insertions(+), 34 deletions(-) create mode 100644 internal/cmd/git/production_branch.go create mode 100644 internal/cmd/git/production_branch_test.go diff --git a/docs/git.md b/docs/git.md index 19078f26..808a89f0 100644 --- a/docs/git.md +++ b/docs/git.md @@ -1,13 +1,14 @@ --- title: "Git connection" -description: "Bind a project to a GitHub repository so that pushing to its default branch deploys." +description: "Bind a project to a GitHub repository so that pushing to its production branch deploys." --- ## What it is A binding between a **project** and a GitHub repository. Once connected, a push -to the repository's default branch deploys the project — no CLI invocation -needed. +to the project's production branch deploys the project — no CLI invocation +needed. The production branch is the repository's default branch unless you +name another. Connecting only records the binding. Volcano never creates push credentials, never pushes on your behalf, and never writes a token into your `.git/config`. @@ -23,8 +24,8 @@ Pushing stays your own `git push`, with the credentials already on your machine. token (`pk-`) and says so when run with a project access token. Which repository a project deploys from is a person's choice. - `root-directory` changes only the subdirectory the connected repository - builds from, and works with a project access token, so a CI job or an agent - can use it. + builds from, and `production-branch` only the branch a push has to land on. + Both work with a project access token, so a CI job or an agent can use them. - What a push actually deploys is governed by the project's Git deploy settings, reported after a successful connect. @@ -35,6 +36,7 @@ Pushing stays your own `git push`, with the credentials already on your machine. | Show the connection | `volcano git status` | | Connect | `volcano git connect [git-url]` | | Change the root directory | `volcano git root-directory ` | +| Change the production branch | `volcano git production-branch ` | | Disconnect | `volcano git disconnect` | ## Seeing what is connected @@ -131,9 +133,10 @@ In a script or a CI job there is nobody to prompt, so a command that would ask fails and says to pass `--yes` rather than cancelling silently. After connecting, start deploying by pushing yourself. Only a push to the -repository's GitHub default branch deploys — `volcano git connect` prints which -branch that is, as `Production branch`. Pushing any other branch is safe and -deploys nothing. +production branch deploys — the repository's GitHub default branch, unless you +[choose another](#changing-the-production-branch). `volcano git connect` prints +which branch that is, as `Production branch`. Pushing any other branch is safe +and deploys nothing. A bare `git push` goes to the repository the binding was read from, since both follow the same routing: @@ -174,6 +177,35 @@ branch deploys, which is nothing while auto-deploy is off. A project with no repository connected has no root directory to change, so the command says so and exits non-zero. +## Changing the production branch + +A push deploys only when it lands on the production branch. That is the +repository's default branch unless you name another: + +```bash +volcano git production-branch release +volcano git production-branch --reset # follow the repository's default branch again +``` + +The branch does not have to exist yet, so a project can point at a branch you +are about to push. Volcano checks only that it is a valid Git branch name. + +A branch you name stays the production branch if the repository's default +branch is renamed or changed on GitHub. With `--reset`, the project follows the +default branch again, including later renames, and the command prints which +branch that is. Volcano reads the default branch from GitHub through the project +owner's GitHub connection: if that connection needs reconnecting, or has lost +access to the repository, the command says so and nothing changes. A repository +with no commits has no default branch yet, so name a branch instead. + +This works with a project access token, so a CI job or an agent can change it. + +Nothing is redeployed. Like `connect`, the command then says what a push to the +production branch deploys, which is nothing while auto-deploy is off. + +A project with no repository connected has no production branch to change, so +the command says so and exits non-zero. + ## Disconnecting ```bash @@ -202,5 +234,6 @@ configured. Run these commands against the cloud API. `volcano git disconnect` reads only the project's own binding, which does not touch GitHub, so it reports that the project has nothing connected instead. -`volcano git root-directory` reports that the integration is not configured, -since no repository can be connected there for it to change. +`volcano git root-directory` and `volcano git production-branch` report that +the integration is not configured, since no repository can be connected there +for them to change. diff --git a/internal/api/git.go b/internal/api/git.go index 5fde83ad..c86dc2ad 100644 --- a/internal/api/git.go +++ b/internal/api/git.go @@ -81,6 +81,19 @@ func (c *Client) SetProjectGitRootDirectory(ctx context.Context, projectID uuid. return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON409, resp.JSON500) } +// SetProjectGitProductionBranch changes only the branch a connected project +// deploys from. An empty branch makes the project follow the repository's +// default branch again; the connection returned names that default. Like +// SetProjectGitRootDirectory it accepts a project access token. +func (c *Client) SetProjectGitProductionBranch(ctx context.Context, projectID uuid.UUID, branch string) (*apiclient.ProjectGitConnection, error) { + resp, err := c.client.SetProjectGitProductionBranchWithResponse(ctx, projectID, + apiclient.SetProjectGitProductionBranchJSONRequestBody{ProductionBranch: branch}) + if err != nil { + return nil, err + } + return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON409, resp.JSON500) +} + // DisconnectProjectGit removes a project's repo connection. The repository // itself is untouched. func (c *Client) DisconnectProjectGit(ctx context.Context, projectID uuid.UUID) error { diff --git a/internal/apiclient/client.gen.go b/internal/apiclient/client.gen.go index e4151772..3f8c9274 100644 --- a/internal/apiclient/client.gen.go +++ b/internal/apiclient/client.gen.go @@ -10259,7 +10259,7 @@ type ServiceKey struct { // SetProjectGitProductionBranchRequest defines model for SetProjectGitProductionBranchRequest. type SetProjectGitProductionBranchRequest struct { - // ProductionBranch The branch a push must land on to deploy. Validated as a Git branch name only — it does not have to exist yet. + // ProductionBranch The branch a push must land on to deploy. Validated as a Git branch name only — it does not have to exist yet. An empty string follows the repository's default branch, including later renames. ProductionBranch string `json:"production_branch"` } diff --git a/internal/cmd/git/git.go b/internal/cmd/git/git.go index 2b9cafc4..60819165 100644 --- a/internal/cmd/git/git.go +++ b/internal/cmd/git/git.go @@ -2,6 +2,7 @@ package git import ( + "context" "errors" "fmt" "io" @@ -28,6 +29,7 @@ your machine.`, cmd.AddCommand(newStatus(deps)) cmd.AddCommand(newConnect(deps)) cmd.AddCommand(newRootDirectory(deps)) + cmd.AddCommand(newProductionBranch(deps)) cmd.AddCommand(newDisconnect(deps)) return cmd } @@ -49,6 +51,20 @@ func ask(in io.Reader, out io.Writer, yes bool, warning, question string) (bool, return confirm.Action(in, out, warning, question) } +// guideBindingEdit is guide for a command that changes one part of a binding. +// Such a write is refused as not connected also where no repository can be +// connected at all, as in local mode, and "connect one" is no advice there. +func guideBindingEdit( + ctx context.Context, deps cliruntime.Deps, service gitconnect.Service, webURL string, err error, +) error { + if errors.Is(err, gitconnect.ErrNotConnected) { + if cannot := service.CanConnect(ctx); cannot != nil { + err = cannot + } + } + return guide(deps, webURL, err) +} + // guide rewrites the failures a user can act on into errors that say what to do // next, and returns anything else unchanged. The command layer returns these // like any other error: main prints them, so guidance belongs in the error diff --git a/internal/cmd/git/helpers_test.go b/internal/cmd/git/helpers_test.go index 5c1d7ab9..8684ef1c 100644 --- a/internal/cmd/git/helpers_test.go +++ b/internal/cmd/git/helpers_test.go @@ -193,11 +193,16 @@ type gitAPI struct { // rootStatus and rootMessage make the root-directory route refuse. rootStatus int rootMessage string + // branchStatus and branchMessage make the production-branch route refuse. + branchStatus int + branchMessage string mu sync.Mutex connectBody map[string]any // rootBody is what the root-directory route was sent. - rootBody map[string]any + rootBody map[string]any + // branchBody is what the production-branch route was sent. + branchBody map[string]any deleted bool connectionReads int // accountReads counts the calls to the caller's own GitHub routes, which @@ -259,6 +264,8 @@ func (a *gitAPI) handle(w http.ResponseWriter, r *http.Request) { a.serveConnect(w, r) case r.Method == http.MethodPut && strings.HasSuffix(r.URL.Path, "/git-connection/root-directory"): a.serveRootDirectory(w, r) + case r.Method == http.MethodPut && strings.HasSuffix(r.URL.Path, "/git-connection/production-branch"): + a.serveProductionBranch(w, r) case r.Method == http.MethodDelete && strings.HasSuffix(r.URL.Path, "/git-connection"): a.serveDisconnect(w) case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/git-deploy-settings"): @@ -439,6 +446,31 @@ func (a *gitAPI) serveRootDirectory(w http.ResponseWriter, r *http.Request) { connectionPayload(a.connected, a.connectedRoot, a.currentRepoID(), a.currentInstallation())) } +// serveProductionBranch changes only the binding's production branch. An empty +// one follows the repository's default, which the platform reads from GitHub +// and answers with; this fixture's default is main. +func (a *gitAPI) serveProductionBranch(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + require.NoError(a.t, json.NewDecoder(r.Body).Decode(&a.branchBody)) + + if a.branchStatus != 0 { + writeGitJSON(a.t, w, a.branchStatus, map[string]any{"error": a.branchMessage}) + return + } + if a.connected == "" { + writeGitJSON(a.t, w, http.StatusNotFound, map[string]any{"error": "project has no repository connected"}) + return + } + a.connectedBranch, _ = a.branchBody["production_branch"].(string) + if a.connectedBranch == "" { + a.connectedBranch = "main" + } + payload := connectionPayload(a.connected, a.connectedRoot, a.currentRepoID(), a.currentInstallation()) + payload["production_branch"] = a.connectedBranch + writeGitJSON(a.t, w, http.StatusOK, payload) +} + func (a *gitAPI) serveDisconnect(w http.ResponseWriter) { a.mu.Lock() defer a.mu.Unlock() @@ -478,6 +510,12 @@ func (a *gitAPI) sentRootBody() map[string]any { return a.rootBody } +func (a *gitAPI) sentBranchBody() map[string]any { + a.mu.Lock() + defer a.mu.Unlock() + return a.branchBody +} + func (a *gitAPI) accountRouteReads() int { a.mu.Lock() defer a.mu.Unlock() diff --git a/internal/cmd/git/production_branch.go b/internal/cmd/git/production_branch.go new file mode 100644 index 00000000..df3dbc6a --- /dev/null +++ b/internal/cmd/git/production_branch.go @@ -0,0 +1,107 @@ +package git + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "github.com/Kong/volcano-cli/internal/gitconnect" + "github.com/Kong/volcano-cli/internal/output" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" +) + +type productionBranchOptions struct { + deps cliruntime.Deps + // branch is the branch to deploy from, "" to follow the repository's + // default branch. + branch string + out io.Writer +} + +func newProductionBranch(deps cliruntime.Deps) *cobra.Command { + var reset bool + cmd := &cobra.Command{ + Use: "production-branch [branch]", + Short: "Change the branch a push must land on to deploy", + Long: fmt.Sprintf(`Change the branch of the connected repository that deploys when pushed to. The +repository stays connected as it is. + +The branch does not have to exist yet. Naming one makes it the project's own +choice: it stays the production branch if the repository's default branch is +renamed or changed. Pass --reset to follow the repository's default branch +again. + +This works with a project access token, so CI or an agent can change it. +Connecting a different repository needs a person's account token: see '%s'. + +Nothing is redeployed. The command says what a push to the production branch +deploys.`, + cliruntime.CommandPath(deps, "git connect")), + Example: " volcano git production-branch release\n volcano git production-branch --reset", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + branch, err := productionBranchArgument(args, reset) + if err != nil { + return err + } + return runProductionBranch(cmd.Context(), productionBranchOptions{ + deps: deps, + branch: branch, + out: cmd.OutOrStdout(), + }) + }, + } + cmd.Flags().BoolVar(&reset, "reset", false, "Follow the repository's default branch") + return cmd +} + +// productionBranchArgument is the branch asked for. Following the default +// takes --reset rather than an empty argument, which is what an unset variable +// in a script looks like. Whether the name is one git accepts is the API's to +// say: it checks it the same way for every client. +func productionBranchArgument(args []string, reset bool) (string, error) { + switch { + case reset && len(args) == 1: + return "", errors.New("pass a branch or --reset, not both") + case reset: + return "", nil + case len(args) == 0: + return "", errors.New("name the branch a push must land on to deploy, such as main, " + + "or pass --reset to follow the repository's default branch") + } + branch := strings.TrimSpace(args[0]) + if branch == "" { + return "", errors.New("the branch is empty; pass --reset to follow the repository's default branch") + } + return branch, nil +} + +func runProductionBranch(ctx context.Context, opts productionBranchOptions) error { + service := gitconnect.NewService(opts.deps) + webURL, _ := service.WebURL() + + project, err := service.Project() + if err != nil { + return err + } + + // Nothing is read first, as for the root directory: the write names only + // the branch, so it cannot replace a repository connected meanwhile. + connection, err := service.SetProductionBranch(ctx, opts.branch) + if err != nil { + return guideBindingEdit(ctx, opts.deps, service, webURL, err) + } + + settings, settingsErr := service.DeploySettings(ctx) + output.GitProductionBranchSet(opts.out, output.GitBinding{ + Connection: connection, + Settings: settings, + SettingsErr: settingsErr, + Project: project.Label(), + }, opts.branch == "") + return nil +} diff --git a/internal/cmd/git/production_branch_test.go b/internal/cmd/git/production_branch_test.go new file mode 100644 index 00000000..6fb690ef --- /dev/null +++ b/internal/cmd/git/production_branch_test.go @@ -0,0 +1,176 @@ +package git + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cliconfig "github.com/Kong/volcano-cli/internal/config" +) + +func TestProductionBranchSetsOnlyTheProductionBranch(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + + out, err := executeGitCommand(t, api.serve(), nil, "", "production-branch", "release") + require.NoError(t, err) + + assert.Equal(t, map[string]any{"production_branch": "release"}, api.sentBranchBody()) + assert.Nil(t, api.sentConnectBody(), "the repository is not rebound") + assert.Nil(t, api.sentRootBody()) + assert.Zero(t, api.accountRouteReads(), "nothing is resolved on GitHub") + assert.Contains(t, out, "Production branch set to release") + assert.Contains(t, out, "Repository: octo/storefront") + assert.Contains(t, out, "Production branch: release") + assert.Contains(t, out, + "Nothing was redeployed. release stays the production branch if the repository's default branch changes.") + assert.Contains(t, out, "A push to release deploys: functions") +} + +// Following the default sends an empty branch, and the default comes back from +// the platform, which read it from GitHub: the CLI does not know it itself. +func TestProductionBranchResetFollowsTheDefaultBranch(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.connectedBranch = "release" + + out, err := executeGitCommand(t, api.serve(), nil, "", "production-branch", "--reset") + require.NoError(t, err) + + assert.Equal(t, map[string]any{"production_branch": ""}, api.sentBranchBody()) + assert.Contains(t, out, "Production branch now follows the repository's default branch (main).") + assert.Contains(t, out, "Production branch: main") + assert.NotContains(t, out, "stays the production branch") + assert.Contains(t, out, "A push to main deploys: functions") +} + +// A push deploys nothing while auto-deploy is off, so the command says that +// rather than implying the next push to the branch deploys. +func TestProductionBranchSaysWhenAPushDeploysNothing(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.autoDeploy = false + + out, err := executeGitCommand(t, api.serve(), nil, "", "production-branch", "release") + require.NoError(t, err) + assert.Contains(t, out, "Auto-deploy is off, so a push to release will not deploy anything.") +} + +func TestProductionBranchSucceedsWhenTheDeploySettingsCannotBeRead(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.deploySettingsStatus = http.StatusInternalServerError + + out, err := executeGitCommand(t, api.serve(), nil, "", "production-branch", "release") + require.NoError(t, err) + assert.Contains(t, out, "Production branch set to release") + assert.Contains(t, out, "its deploy settings could not be read") +} + +// CI and agents hold project access tokens, which this route accepts. +func TestProductionBranchWorksWithAProjectAccessToken(t *testing.T) { + setGitCommandTestHome(t) + setGitCommandTestToken(t, cliconfig.ProjectTokenPrefix+"token") + api := newGitAPI(t) + api.connected = "octo/storefront" + + _, err := executeGitCommand(t, api.serve(), nil, "", "production-branch", "release") + require.NoError(t, err) + assert.Equal(t, map[string]any{"production_branch": "release"}, api.sentBranchBody()) +} + +func TestProductionBranchRefusesWhatCannotNameABranch(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"production-branch"}, "name the branch a push must land on to deploy, such as main, or pass --reset"}, + {[]string{"production-branch", ""}, "the branch is empty; pass --reset to follow the repository's default branch"}, + {[]string{"production-branch", " "}, "the branch is empty; pass --reset to follow the repository's default branch"}, + {[]string{"production-branch", "release", "--reset"}, "pass a branch or --reset, not both"}, + } { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + + _, err := executeGitCommand(t, api.serve(), nil, "", tc.args...) + require.Error(t, err, "%q", tc.args) + assert.Contains(t, err.Error(), tc.want, "%q", tc.args) + assert.Nil(t, api.sentBranchBody(), "%q", tc.args) + } +} + +// The server checks a branch name and reads the default branch, and its reason +// is shown as it gave it. +func TestProductionBranchShowsTheServersRefusal(t *testing.T) { + for _, tc := range []struct { + args []string + status int + message string + }{ + {[]string{"release..x"}, http.StatusBadRequest, "That is not a valid Git branch name."}, + { + []string{"--reset"}, + http.StatusBadRequest, + "The repository has no default branch. Name a production branch, or add an initial commit and try again.", + }, + { + []string{"--reset"}, + http.StatusConflict, + "The repository's default branch could not be read through the project owner's GitHub connection. " + + "Reconnect GitHub, then try again.", + }, + {[]string{"release"}, http.StatusForbidden, "project access token is read-only"}, + } { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.branchStatus, api.branchMessage = tc.status, tc.message + + _, err := executeGitCommand(t, api.serve(), nil, "", append([]string{"production-branch"}, tc.args...)...) + require.Error(t, err, "%q", tc.args) + assert.Contains(t, err.Error(), "failed to set the production branch: ", "%q", tc.args) + assert.Contains(t, err.Error(), tc.message, "%q", tc.args) + } +} + +func TestProductionBranchOnAProjectWithNothingConnected(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + + _, err := executeGitCommand(t, api.serve(), nil, "", "production-branch", "release") + require.Error(t, err) + assert.Contains(t, err.Error(), "this project is not connected to a repository") + assert.Contains(t, err.Error(), "Connect one with volcano git connect") +} + +// Where the API has no GitHub App, as in local mode, nothing can be connected, +// so that is the reason given rather than advice to connect. +func TestProductionBranchWhereNoRepositoryCanBeConnected(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.providerStatus = http.StatusServiceUnavailable + + _, err := executeGitCommand(t, api.serve(), nil, "", "production-branch", "--reset") + require.Error(t, err) + assert.Contains(t, err.Error(), "git provider integration is not configured on this API") + assert.Contains(t, err.Error(), "If this is local mode, that is expected") + assert.NotContains(t, err.Error(), "Connect one with") +} + +func TestProductionBranchReportsAMissingProject(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.projectMissing = true + + _, err := executeGitCommand(t, api.serve(), nil, "", "production-branch", "release") + require.Error(t, err) + assert.Contains(t, err.Error(), "the selected project does not exist") + assert.Contains(t, err.Error(), "Select one with volcano use ") +} diff --git a/internal/cmd/git/root_directory.go b/internal/cmd/git/root_directory.go index f5e428ac..0e933c92 100644 --- a/internal/cmd/git/root_directory.go +++ b/internal/cmd/git/root_directory.go @@ -95,13 +95,7 @@ func runRootDirectory(ctx context.Context, opts rootDirectoryOptions) error { // changes nothing when the directory is already the one asked for. connection, err := service.SetRootDirectory(ctx, opts.rootDirectory, nil) if err != nil { - // "Connect one" is only advice where a repository can be connected. - if errors.Is(err, gitconnect.ErrNotConnected) { - if cannot := service.CanConnect(ctx); cannot != nil { - err = cannot - } - } - return guide(opts.deps, webURL, err) + return guideBindingEdit(ctx, opts.deps, service, webURL, err) } // Read afterwards, like connect: the directory is set either way, and diff --git a/internal/gitconnect/gitconnect.go b/internal/gitconnect/gitconnect.go index 9c184a7e..f2ae2db5 100644 --- a/internal/gitconnect/gitconnect.go +++ b/internal/gitconnect/gitconnect.go @@ -358,6 +358,28 @@ func (s Service) SetRootDirectory( return connection, nil } +// SetProductionBranch changes only the branch the project deploys from, +// leaving the repository bound alone. A project access token may make it, like +// SetRootDirectory. An empty branch makes the project follow the repository's +// default branch again, and the connection returned names that default. +func (s Service) SetProductionBranch(ctx context.Context, branch string) (*apiclient.ProjectGitConnection, error) { + authenticated, err := s.current() + if err != nil { + return nil, err + } + + connection, err := authenticated.API.SetProjectGitProductionBranch(ctx, authenticated.ProjectID, branch) + if err != nil { + // The route answers 404 both for a project with no repository and for + // a project that does not exist, like the binding read. + if api.Status(err) == http.StatusNotFound { + return nil, s.explainNotFound(ctx) + } + return nil, classify(err, "failed to set the production branch") + } + return connection, nil +} + // CanConnect reports ErrProviderNotConfigured when this API has no GitHub App, // so no repository can be connected through it at all. That is the case in // local mode, and it is a better answer than "connect one" for a project with diff --git a/internal/output/git.go b/internal/output/git.go index 09b39ff6..f2019156 100644 --- a/internal/output/git.go +++ b/internal/output/git.go @@ -84,6 +84,26 @@ func GitRootDirectorySet(w io.Writer, binding GitBinding) { gitDeploySettings(w, binding) } +// GitProductionBranchSet renders a production branch that was just set, +// followed by what a push to it deploys. followsDefault says the branch was +// reset to follow the repository's default: the connection names the branch +// either way, and nothing in it says which of the two it is. +func GitProductionBranchSet(w io.Writer, binding GitBinding, followsDefault bool) { + branch := binding.Connection.ProductionBranch + if followsDefault { + Success(w, "Production branch now follows the repository's default branch (%s).", branch) + } else { + Success(w, "Production branch set to %s", branch) + } + gitBindingDetail(w, theme.On(w), binding) + if followsDefault { + Note(w, "Nothing was redeployed.") + } else { + Note(w, "Nothing was redeployed. %s stays the production branch if the repository's default branch changes.", branch) + } + gitDeploySettings(w, binding) +} + // GitDisconnected renders a removed binding. The repository is untouched, which // is worth saying: "disconnect" reads as destructive and is not. func GitDisconnected(w io.Writer, repoFullName string) { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 87870df9..8b0aa38b 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -3082,6 +3082,11 @@ paths: Setting the branch here marks it as the project's own choice, so a later default-branch rename on GitHub no longer moves it. Projects that never set one keep following the repository's default branch. + + An empty string undoes that choice: the project deploys from the + repository's default branch again and follows later renames. Volcano + reads the default branch from GitHub through the project owner's + connection, and the response carries it. operationId: setProjectGitProductionBranch security: - UserToken: [] @@ -3103,8 +3108,9 @@ paths: $ref: '#/components/schemas/ProjectGitConnection' '400': description: | - Malformed request body, or a production_branch that is not a valid - Git branch name + Malformed request body, a production_branch that is not a valid + Git branch name, or an empty one for a repository with no default + branch yet content: application/json: schema: @@ -3131,7 +3137,11 @@ paths: schema: $ref: '#/components/schemas/Error' '409': - description: A Git source transition is pending + description: | + A Git source transition is pending, or an empty production_branch + could not read the default branch because the project owner's + GitHub connection needs reconnecting or lost access to the + repository content: application/json: schema: @@ -20559,9 +20569,8 @@ components: properties: production_branch: type: string - minLength: 1 maxLength: 255 - description: The branch a push must land on to deploy. Validated as a Git branch name only — it does not have to exist yet. + description: The branch a push must land on to deploy. Validated as a Git branch name only — it does not have to exist yet. An empty string follows the repository's default branch, including later renames. required: - production_branch SetProjectGitRootDirectoryRequest: diff --git a/tests/e2e/localmode/git_test.go b/tests/e2e/localmode/git_test.go index a45c8ab0..192adc5d 100644 --- a/tests/e2e/localmode/git_test.go +++ b/tests/e2e/localmode/git_test.go @@ -1,13 +1,14 @@ package localmode import ( + "strings" "testing" ) -// requireLocalModeLeavesGitToTheCloud points `volcano git root-directory` at -// the local stack and checks it says why it cannot work there: the local stack -// has no GitHub App, so no repository can be connected for a root directory to -// belong to. +// requireLocalModeLeavesGitToTheCloud points the commands that change one part +// of a binding at the local stack and checks they say why they cannot work +// there: the local stack has no GitHub App, so no repository can be connected +// for a root directory or a production branch to belong to. func requireLocalModeLeavesGitToTheCloud(t *testing.T, binary string, env []string, dir string) { t.Helper() info := fetchVolcanoLocalModeE2EInfo(t, env) @@ -17,11 +18,17 @@ func requireLocalModeLeavesGitToTheCloud(t *testing.T, binary string, env []stri "VOLCANO_PROJECT_ID="+info.ProjectID, ) - output, err := runVolcanoLocalModeE2EAllowFailure(t, binary, local, dir, "git", "root-directory", "apps/web") - if err == nil { - t.Fatalf("expected volcano git root-directory to fail against the local stack\n%s", output) + for _, args := range [][]string{ + {"git", "root-directory", "apps/web"}, + {"git", "production-branch", "release"}, + } { + command := "volcano " + strings.Join(args, " ") + output, err := runVolcanoLocalModeE2EAllowFailure(t, binary, local, dir, args...) + if err == nil { + t.Fatalf("expected %s to fail against the local stack\n%s", command, output) + } + requireContains(t, output, "git provider integration is not configured on this API") + requireContains(t, output, "If this is local mode, that is expected") + requireNotContains(t, output, "Connect one with") } - requireContains(t, output, "git provider integration is not configured on this API") - requireContains(t, output, "If this is local mode, that is expected") - requireNotContains(t, output, "Connect one with") } From c2ab0c31d2e58c1c0360d0917a6e944ac5bfc957 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:31:05 -0400 Subject: [PATCH 2/3] feat(git): keep a pinned production branch when connecting again The connection now says whether its production branch follows the repository's default or was pinned for the project. connect treats a branch other than the default as stale only when it follows the default, and binding the same repository again sends a pinned branch back, since leaving it out follows the default. status and every git command mark the branch as pinned or following the default. Vendors production_branch_follows_default from Kong/volcano-hosting#2184. --- docs/git.md | 25 +++-- internal/apiclient/client.gen.go | 5 +- internal/cmd/git/connect.go | 9 +- internal/cmd/git/connect_test.go | 104 ++++++++++++++++++++- internal/cmd/git/helpers_test.go | 68 ++++++++++---- internal/cmd/git/production_branch.go | 2 +- internal/cmd/git/production_branch_test.go | 22 ++++- internal/cmd/git/status_test.go | 18 +++- internal/gitconnect/gitconnect.go | 10 ++ internal/output/git.go | 13 ++- openapi/openapi.yaml | 25 ++++- 11 files changed, 254 insertions(+), 47 deletions(-) diff --git a/docs/git.md b/docs/git.md index 808a89f0..637be2d8 100644 --- a/docs/git.md +++ b/docs/git.md @@ -46,9 +46,12 @@ volcano git status ``` This reports the project's own binding — the repository, the branch a push has -to land on, the subdirectory it builds from, and what a push deploys. It does -not contact GitHub, so it tells you what the platform recorded rather than -whether that recording still works. +to land on, the subdirectory it builds from, and what a push deploys. The branch +is marked `(follows the default)` when it is the repository's default branch and +moves with it, or `(pinned)` when it was +[chosen for the project](#changing-the-production-branch). It does not contact +GitHub, so it tells you what the platform recorded rather than whether that +recording still works. A project with nothing connected is not an error: it says so and exits 0, so the command is usable in a conditional. A project that does not exist — a deleted @@ -120,9 +123,11 @@ volcano git connect --root-directory "" # back to the repository root Connecting is idempotent: running it again on a project already bound to the same repository reports that nothing changed and writes nothing. "The same" means every part of the binding — the repository, the App installation, the root -directory, and the production branch — so a repository whose GitHub default -branch has moved is *not* unchanged, and re-running connect updates the branch a -push has to land on. A repository renamed on +directory, and the production branch — so for a project that follows the +default branch, a repository whose GitHub default branch has moved is *not* +unchanged, and re-running connect updates the branch a push has to land on. A +pinned branch is left as it is, and connecting the same repository again keeps +it. A repository renamed on GitHub is still the same repository — the binding follows its id, not its name — so re-running connect simply refreshes the name. Re-running it with a different `--root-directory` edits that, as `volcano git root-directory` does. Pointing it at a *different* repository asks for @@ -190,10 +195,12 @@ volcano git production-branch --reset # follow the repository's default bran The branch does not have to exist yet, so a project can point at a branch you are about to push. Volcano checks only that it is a valid Git branch name. -A branch you name stays the production branch if the repository's default -branch is renamed or changed on GitHub. With `--reset`, the project follows the +A branch you name is pinned: it stays the production branch if the repository's +default branch is renamed or changed on GitHub, and re-running +`volcano git connect` for the same repository keeps it. Naming the branch that +is the default today pins it too. With `--reset`, the project follows the default branch again, including later renames, and the command prints which -branch that is. Volcano reads the default branch from GitHub through the project +branch that is. `volcano git status` shows which of the two applies. Volcano reads the default branch from GitHub through the project owner's GitHub connection: if that connection needs reconnecting, or has lost access to the repository, the command says so and nothing changes. A repository with no commits has no default branch yet, so name a branch instead. diff --git a/internal/apiclient/client.gen.go b/internal/apiclient/client.gen.go index 3f8c9274..758eb9b0 100644 --- a/internal/apiclient/client.gen.go +++ b/internal/apiclient/client.gen.go @@ -9430,7 +9430,10 @@ type ProjectFrontendCustomDomainVerificationStatus string type ProjectGitConnection struct { // ProductionBranch The branch a push must land on to deploy. Follows the repository's GitHub default branch unless the project set its own, which a default-branch rename on GitHub then leaves alone. ProductionBranch string `json:"production_branch"` - RepoFullName string `json:"repo_full_name"` + + // ProductionBranchFollowsDefault True when `production_branch` follows the repository's default branch and moves with a default-branch rename on GitHub. False when the project pinned a branch, even one that matches the current default. Pin a branch with `PUT /projects/{id}/git-connection/production-branch`; send an empty `production_branch` there to follow the default again. + ProductionBranchFollowsDefault bool `json:"production_branch_follows_default"` + RepoFullName string `json:"repo_full_name"` // RepoId Stable GitHub repository id (repository.id), the authoritative binding. RepoId int64 `json:"repo_id"` diff --git a/internal/cmd/git/connect.go b/internal/cmd/git/connect.go index 59bef3e9..f20b157e 100644 --- a/internal/cmd/git/connect.go +++ b/internal/cmd/git/connect.go @@ -384,8 +384,13 @@ func sameRepositoryBinding(existing *apiclient.ProjectGitConnection, target *git if !strings.EqualFold(existing.RepoFullName, target.Repository.FullName) { return false } - // The bind re-resolves the production branch from GitHub's live default, so - // a repository whose default moved has a stale one recorded — and that run + // A branch the project pinned is its own choice, kept on purpose whatever + // the default is now. + if !existing.ProductionBranchFollowsDefault { + return true + } + // The bind re-resolves a followed branch from GitHub's live default, so a + // repository whose default moved has a stale one recorded — and that run // is exactly the one an operator needs told, not reported as unchanged. return existing.ProductionBranch == target.Repository.DefaultBranch } diff --git a/internal/cmd/git/connect_test.go b/internal/cmd/git/connect_test.go index cf976133..156229ac 100644 --- a/internal/cmd/git/connect_test.go +++ b/internal/cmd/git/connect_test.go @@ -106,7 +106,91 @@ func TestConnectRebindsWhenTheDefaultBranchMoved(t *testing.T) { assert.Contains(t, out, "✓ Connected octo/storefront") assert.NotContains(t, out, "already connected") assert.NotNil(t, api.sentConnectBody()) - assert.Contains(t, out, "Production branch: main") + assert.Contains(t, out, "Production branch: main (follows the default)") +} + +// A branch the project pinned is its own choice, not a stale default, so it is +// left alone — including one that happens to be the default today. +func TestConnectLeavesAPinnedBranchAlone(t *testing.T) { + for _, branch := range []string{"release", "main"} { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.connectedBranch, api.connectedPinned = branch, true + + out, err := executeGitCommand(t, api.serve(), &gitRunner{stdout: originRemoteOutput}, "", "connect") + require.NoError(t, err, branch) + + assert.Contains(t, out, "octo/storefront is already connected to this project.", branch) + assert.Contains(t, out, "Production branch: "+branch+" (pinned)", branch) + assert.Nil(t, api.sentConnectBody(), branch) + } +} + +// Leaving the branch out of a bind follows the default and discards a pin, so +// binding the same repository again sends a pinned branch back. +func TestConnectRebindingTheSameRepositoryKeepsAPinnedBranch(t *testing.T) { + for _, branch := range []string{"release", "main"} { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.connectedInstallation = 999999 + api.connectedBranch, api.connectedPinned = branch, true + + out, err := executeGitCommand(t, api.serve(), &gitRunner{stdout: originRemoteOutput}, "", "connect") + require.NoError(t, err, branch) + + require.NotNil(t, api.sentConnectBody(), branch) + assert.Equal(t, branch, api.sentConnectBody()["production_branch"], branch) + assert.Contains(t, out, "Production branch: "+branch+" (pinned)", branch) + } +} + +func TestConnectRebindingTheSameRepositoryKeepsFollowingTheDefault(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.connectedInstallation = 999999 + + out, err := executeGitCommand(t, api.serve(), &gitRunner{stdout: originRemoteOutput}, "", "connect") + require.NoError(t, err) + + require.NotNil(t, api.sentConnectBody()) + assert.NotContains(t, api.sentConnectBody(), "production_branch") + assert.Contains(t, out, "Production branch: main (follows the default)") +} + +// A pin was chosen for the repository being left, so a different one starts +// from its own default. +func TestConnectToAnotherRepositoryDoesNotCarryThePin(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "acme/old-store" + api.connectedBranch, api.connectedPinned = "release", true + + out, err := executeGitCommand(t, api.serve(), &gitRunner{stdout: originRemoteOutput}, "", "connect", "--yes") + require.NoError(t, err) + + require.NotNil(t, api.sentConnectBody()) + assert.NotContains(t, api.sentConnectBody(), "production_branch") + assert.Contains(t, out, "Production branch: main (follows the default)") +} + +// Changing only the root directory of a project with a pinned branch takes +// the narrower write, which leaves the branch alone. +func TestConnectChangingTheRootDirectoryKeepsAPinnedBranch(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.connectedBranch, api.connectedPinned = "release", true + + out, err := executeGitCommand(t, api.serve(), &gitRunner{stdout: originRemoteOutput}, + "", "connect", "--root-directory", "apps/api") + require.NoError(t, err) + + assert.Nil(t, api.sentConnectBody()) + assert.Equal(t, map[string]any{"root_directory": "apps/api"}, api.sentRootBody()) + assert.Contains(t, out, "Production branch: release (pinned)") } // GitHub preserves the case an owner typed but does not treat it as @@ -931,6 +1015,24 @@ func TestConnectRefusesWhenOnlyTheRootDirectoryMovedUnderIt(t *testing.T) { assert.Nil(t, api.sentRootBody(), "apps/new must not be overwritten") } +// A rebind sends a pinned branch back, so someone making the project follow +// the default meanwhile — the branch name unchanged — must stop it, or the +// rebind would pin it again. +func TestConnectRefusesWhenOnlyThePinMovedUnderIt(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.connectedInstallation = 999999 + api.connectedBranch, api.connectedPinned = "main", true + api.followAfterRead = true + + _, err := executeGitCommand(t, api.serve(), &gitRunner{stdout: originRemoteOutput}, "", "connect") + require.Error(t, err) + + assert.Contains(t, err.Error(), "changed while this command was running") + assert.Nil(t, api.sentConnectBody(), "following the default must not be reverted") +} + // Nothing is written when the binding already matches, so a concurrent edit // cannot be reverted on that path at all — it simply is not touched. func TestConnectWritesNothingSoCannotRevertAConcurrentEdit(t *testing.T) { diff --git a/internal/cmd/git/helpers_test.go b/internal/cmd/git/helpers_test.go index 8684ef1c..edfc3f59 100644 --- a/internal/cmd/git/helpers_test.go +++ b/internal/cmd/git/helpers_test.go @@ -148,6 +148,9 @@ type gitAPI struct { // connectedBranch overrides the binding's cached production branch, which // goes stale when the repository's GitHub default branch moves. connectedBranch string + // connectedPinned marks that branch as the project's own choice rather than + // the repository's default followed. + connectedPinned bool // connectedAfterRead makes the binding change on the next read, modelling // something else repointing the project while a prompt is open. connectedAfterRead string @@ -163,6 +166,9 @@ type gitAPI struct { // rootAfterRead changes only the binding's root directory on the next read, // leaving the repository alone. rootAfterRead string + // followAfterRead makes the binding follow the default on the next read, + // leaving the branch name as it is. + followAfterRead bool // connectionUpdated overrides the binding's updated_at, which the platform // bumps whenever the row really changes. connectionUpdated string @@ -371,16 +377,16 @@ func (a *gitAPI) serveConnection(w http.ResponseWriter) { case a.rootAfterRead != "": a.connectedRoot, a.rootAfterRead = a.rootAfterRead, "" a.connectionUpdated = "2026-08-18T00:00:01Z" + case a.followAfterRead: + // updated_at is left alone, so only the flag can tell. + a.connectedPinned, a.followAfterRead = false, false } } if a.connected == "" { writeGitJSON(a.t, w, http.StatusNotFound, map[string]any{"error": "project has no repo connection"}) return } - payload := connectionPayload(a.connected, a.connectedRoot, a.currentRepoID(), a.currentInstallation()) - if a.connectedBranch != "" { - payload["production_branch"] = a.connectedBranch - } + payload := a.currentPayload() if a.connectionUpdated != "" { payload["updated_at"] = a.connectionUpdated } @@ -421,8 +427,34 @@ func (a *gitAPI) serveConnect(w http.ResponseWriter, r *http.Request) { if id, ok := a.connectBody["installation_id"].(float64); ok { a.connectedInstallation = int64(id) } - writeGitJSON(a.t, w, http.StatusOK, - connectionPayload(a.connected, a.connectedRoot, a.connectedRepoID, a.currentInstallation())) + // As the platform does: an omitted branch follows the default, discarding + // a pin; the branch already stored keeps whatever it was; any other branch + // is the project's own choice, and the default itself is followed. + switch branch, named := a.connectBody["production_branch"].(string); { + case !named || branch == "": + a.connectedBranch, a.connectedPinned = "", false + case branch == a.currentBranch(): + default: + a.connectedBranch, a.connectedPinned = branch, branch != "main" + } + writeGitJSON(a.t, w, http.StatusOK, a.currentPayload()) +} + +// currentBranch is the binding's production branch: main, the repository's +// default in this fixture, unless a test set another. +func (a *gitAPI) currentBranch() string { + if a.connectedBranch != "" { + return a.connectedBranch + } + return "main" +} + +// currentPayload is the binding as the platform reports it. +func (a *gitAPI) currentPayload() map[string]any { + payload := connectionPayload(a.connected, a.connectedRoot, a.currentRepoID(), a.currentInstallation()) + payload["production_branch"] = a.currentBranch() + payload["production_branch_follows_default"] = !a.connectedPinned + return payload } // serveRootDirectory changes only the binding's root directory, answering 404 @@ -442,8 +474,7 @@ func (a *gitAPI) serveRootDirectory(w http.ResponseWriter, r *http.Request) { } a.connectedRoot, _ = a.rootBody["root_directory"].(string) a.connectionUpdated = "2026-08-18T00:00:02Z" - writeGitJSON(a.t, w, http.StatusOK, - connectionPayload(a.connected, a.connectedRoot, a.currentRepoID(), a.currentInstallation())) + writeGitJSON(a.t, w, http.StatusOK, a.currentPayload()) } // serveProductionBranch changes only the binding's production branch. An empty @@ -463,12 +494,8 @@ func (a *gitAPI) serveProductionBranch(w http.ResponseWriter, r *http.Request) { return } a.connectedBranch, _ = a.branchBody["production_branch"].(string) - if a.connectedBranch == "" { - a.connectedBranch = "main" - } - payload := connectionPayload(a.connected, a.connectedRoot, a.currentRepoID(), a.currentInstallation()) - payload["production_branch"] = a.connectedBranch - writeGitJSON(a.t, w, http.StatusOK, payload) + a.connectedPinned = a.connectedBranch != "" + writeGitJSON(a.t, w, http.StatusOK, a.currentPayload()) } func (a *gitAPI) serveDisconnect(w http.ResponseWriter) { @@ -530,12 +557,13 @@ func (a *gitAPI) disconnectCalled() bool { func connectionPayload(fullName, rootDirectory string, repoID, installationID int64) map[string]any { return map[string]any{ - "repo_installation_id": installationID, - "repo_id": repoID, - "repo_full_name": fullName, - "root_directory": rootDirectory, - "production_branch": "main", - "updated_at": "2026-08-18T00:00:00Z", + "repo_installation_id": installationID, + "repo_id": repoID, + "repo_full_name": fullName, + "root_directory": rootDirectory, + "production_branch": "main", + "production_branch_follows_default": true, + "updated_at": "2026-08-18T00:00:00Z", } } diff --git a/internal/cmd/git/production_branch.go b/internal/cmd/git/production_branch.go index df3dbc6a..2d7865c6 100644 --- a/internal/cmd/git/production_branch.go +++ b/internal/cmd/git/production_branch.go @@ -102,6 +102,6 @@ func runProductionBranch(ctx context.Context, opts productionBranchOptions) erro Settings: settings, SettingsErr: settingsErr, Project: project.Label(), - }, opts.branch == "") + }) return nil } diff --git a/internal/cmd/git/production_branch_test.go b/internal/cmd/git/production_branch_test.go index 6fb690ef..1569a022 100644 --- a/internal/cmd/git/production_branch_test.go +++ b/internal/cmd/git/production_branch_test.go @@ -24,26 +24,42 @@ func TestProductionBranchSetsOnlyTheProductionBranch(t *testing.T) { assert.Zero(t, api.accountRouteReads(), "nothing is resolved on GitHub") assert.Contains(t, out, "Production branch set to release") assert.Contains(t, out, "Repository: octo/storefront") - assert.Contains(t, out, "Production branch: release") + assert.Contains(t, out, "Production branch: release (pinned)") assert.Contains(t, out, "Nothing was redeployed. release stays the production branch if the repository's default branch changes.") assert.Contains(t, out, "A push to release deploys: functions") } +// Naming the branch that is the default today still pins it: it stays when +// the default changes. +func TestProductionBranchPinsTheDefaultBranchByName(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + + out, err := executeGitCommand(t, api.serve(), nil, "", "production-branch", "main") + require.NoError(t, err) + + assert.Equal(t, map[string]any{"production_branch": "main"}, api.sentBranchBody()) + assert.Contains(t, out, "Production branch set to main") + assert.Contains(t, out, "Production branch: main (pinned)") + assert.Contains(t, out, "main stays the production branch if the repository's default branch changes.") +} + // Following the default sends an empty branch, and the default comes back from // the platform, which read it from GitHub: the CLI does not know it itself. func TestProductionBranchResetFollowsTheDefaultBranch(t *testing.T) { setGitCommandTestHome(t) api := newGitAPI(t) api.connected = "octo/storefront" - api.connectedBranch = "release" + api.connectedBranch, api.connectedPinned = "release", true out, err := executeGitCommand(t, api.serve(), nil, "", "production-branch", "--reset") require.NoError(t, err) assert.Equal(t, map[string]any{"production_branch": ""}, api.sentBranchBody()) assert.Contains(t, out, "Production branch now follows the repository's default branch (main).") - assert.Contains(t, out, "Production branch: main") + assert.Contains(t, out, "Production branch: main (follows the default)") assert.NotContains(t, out, "stays the production branch") assert.Contains(t, out, "A push to main deploys: functions") } diff --git a/internal/cmd/git/status_test.go b/internal/cmd/git/status_test.go index 79938af4..3d345d9e 100644 --- a/internal/cmd/git/status_test.go +++ b/internal/cmd/git/status_test.go @@ -20,11 +20,27 @@ func TestStatusReportsTheBinding(t *testing.T) { assert.Contains(t, out, "Project: Storefront ("+gitProjectID+")") assert.Contains(t, out, "Repository: octo/storefront") - assert.Contains(t, out, "Production branch: main") + assert.Contains(t, out, "Production branch: main (follows the default)") assert.Contains(t, out, "Root directory: apps/api") assert.Contains(t, out, "A push to main deploys: functions, frontend web") } +// A pinned branch is said to be one, even when it is the default today: it +// stays put when the default changes, and a followed one does not. +func TestStatusSaysWhenTheProductionBranchIsPinned(t *testing.T) { + for _, branch := range []string{"release", "main"} { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.connectedBranch, api.connectedPinned = branch, true + + out, err := executeGitCommand(t, api.serve(), nil, "", "status") + require.NoError(t, err, branch) + assert.Contains(t, out, "Production branch: "+branch+" (pinned)", branch) + assert.NotContains(t, out, "follows the default", branch) + } +} + // Status reads the project's own binding and nothing else. Contacting GitHub // would make a read of recorded state into a check of whether it still works, // which is a different and much more expensive question. diff --git a/internal/gitconnect/gitconnect.go b/internal/gitconnect/gitconnect.go index f2ae2db5..1ab68e60 100644 --- a/internal/gitconnect/gitconnect.go +++ b/internal/gitconnect/gitconnect.go @@ -294,6 +294,11 @@ func (s Service) resolveThroughConnection( // three provider round-trips plus however long the user takes to answer. Within // it another actor can point the project somewhere the user was never shown. // Re-reading narrows that; the API has no conditional write to close it. +// +// A bind that leaves out the production branch follows the repository's +// default, discarding a branch the project pinned. Binding the same repository +// again keeps the pin by sending it back; a different repository starts from +// its own default, since a pin chosen for the old one means nothing there. func (s Service) Connect( ctx context.Context, target Target, rootDirectory string, expected *apiclient.ProjectGitConnection, ) (*apiclient.ProjectGitConnection, error) { @@ -315,6 +320,10 @@ func (s Service) Connect( if trimmed := strings.TrimSpace(rootDirectory); trimmed != "" { body.RootDirectory = &trimmed } + if expected != nil && expected.RepoId == repositoryID && !expected.ProductionBranchFollowsDefault { + pinned := expected.ProductionBranch + body.ProductionBranch = &pinned + } connection, err := authenticated.API.ConnectProjectGit(ctx, authenticated.ProjectID, body) if err != nil { @@ -469,6 +478,7 @@ func sameBinding(a, b *apiclient.ProjectGitConnection) bool { a.RepoInstallationId == b.RepoInstallationId && a.RootDirectory == b.RootDirectory && a.ProductionBranch == b.ProductionBranch && + a.ProductionBranchFollowsDefault == b.ProductionBranchFollowsDefault && strings.EqualFold(a.RepoFullName, b.RepoFullName) } diff --git a/internal/output/git.go b/internal/output/git.go index f2019156..5d7deb2d 100644 --- a/internal/output/git.go +++ b/internal/output/git.go @@ -85,11 +85,10 @@ func GitRootDirectorySet(w io.Writer, binding GitBinding) { } // GitProductionBranchSet renders a production branch that was just set, -// followed by what a push to it deploys. followsDefault says the branch was -// reset to follow the repository's default: the connection names the branch -// either way, and nothing in it says which of the two it is. -func GitProductionBranchSet(w io.Writer, binding GitBinding, followsDefault bool) { +// followed by what a push to it deploys. +func GitProductionBranchSet(w io.Writer, binding GitBinding) { branch := binding.Connection.ProductionBranch + followsDefault := binding.Connection.ProductionBranchFollowsDefault if followsDefault { Success(w, "Production branch now follows the repository's default branch (%s).", branch) } else { @@ -117,7 +116,11 @@ func gitBindingDetail(w io.Writer, on bool, binding GitBinding) { kv(w, on, "Project", "%s", binding.Project) } kv(w, on, "Repository", "%s", connection.RepoFullName) - kv(w, on, "Production branch", "%s", connection.ProductionBranch) + if connection.ProductionBranchFollowsDefault { + kv(w, on, "Production branch", "%s (follows the default)", connection.ProductionBranch) + } else { + kv(w, on, "Production branch", "%s (pinned)", connection.ProductionBranch) + } if root := strings.TrimSpace(connection.RootDirectory); root != "" { kv(w, on, "Root directory", "%s", root) } diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 8b0aa38b..14ee3fa6 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -3080,13 +3080,15 @@ paths: to be pushed — the case a repository created empty depends on. Setting the branch here marks it as the project's own choice, so a later - default-branch rename on GitHub no longer moves it. Projects that never + default-branch rename on GitHub no longer moves it, and the response + reports `production_branch_follows_default: false`. Projects that never set one keep following the repository's default branch. An empty string undoes that choice: the project deploys from the repository's default branch again and follows later renames. Volcano reads the default branch from GitHub through the project owner's - connection, and the response carries it. + connection, and the response carries it with + `production_branch_follows_default: true`. operationId: setProjectGitProductionBranch security: - UserToken: [] @@ -3159,8 +3161,8 @@ paths: - Git Connections summary: Set the directory a project builds from description: | - Changes only the root directory, leaving the repository and production - branch alone. Unlike PUT /projects/{id}/git-connection, this accepts a + Changes only the root directory, leaving the repository, the production + branch and `production_branch_follows_default` alone. Unlike PUT /projects/{id}/git-connection, this accepts a project access token, so CI or an agent can move a project within its repository without being able to change the repository. @@ -3235,6 +3237,11 @@ paths: - Projects - Git Connections summary: Get a project's repo connection + description: | + Returns the repository binding. `production_branch_follows_default` + tells a branch that tracks the repository's default from one the + project pinned: a client rebinding the same repository sends a pinned + branch back to keep it. operationId: getProjectGitConnection security: - UserToken: [] @@ -3287,6 +3294,12 @@ paths: user token, then persists repository metadata only from that validated GitHub response. + Omitting production_branch follows the repository's default and + discards a pinned branch. To keep a pin when rebinding the same + repository, read the connection first and send production_branch back + when `production_branch_follows_default` is false. The response reports + the result in the same field. + Project access tokens are refused. Which repository a project deploys from is a person's decision, made with a platform token or in the dashboard. A project access token can still change the root directory @@ -20359,6 +20372,9 @@ components: production_branch: type: string description: The branch a push must land on to deploy. Follows the repository's GitHub default branch unless the project set its own, which a default-branch rename on GitHub then leaves alone. + production_branch_follows_default: + type: boolean + description: True when `production_branch` follows the repository's default branch and moves with a default-branch rename on GitHub. False when the project pinned a branch, even one that matches the current default. Pin a branch with `PUT /projects/{id}/git-connection/production-branch`; send an empty `production_branch` there to follow the default again. updated_at: type: string format: date-time @@ -20368,6 +20384,7 @@ components: - repo_full_name - root_directory - production_branch + - production_branch_follows_default - updated_at ProjectGitDeploySettings: type: object From 97107b145da74bcd38ed2d6900beacc802290f4e Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:54:01 -0400 Subject: [PATCH 3/3] feat(git): take the production-branch contract as hosting merged it Copies the production-branch route from hosting's bundled spec at 31530184c, verbatim: a 429 when GitHub rate-limits reading the default branch, and the reworded 400. The CLI shows the 429's message like the other refusals, and the docs say nothing changes then. --- docs/git.md | 9 ++++++--- internal/api/git.go | 2 +- internal/apiclient/client.gen.go | 8 ++++++++ internal/cmd/git/production_branch_test.go | 7 ++++++- openapi/openapi.yaml | 12 ++++++++++-- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/git.md b/docs/git.md index 637be2d8..7c27c79f 100644 --- a/docs/git.md +++ b/docs/git.md @@ -200,9 +200,12 @@ default branch is renamed or changed on GitHub, and re-running `volcano git connect` for the same repository keeps it. Naming the branch that is the default today pins it too. With `--reset`, the project follows the default branch again, including later renames, and the command prints which -branch that is. `volcano git status` shows which of the two applies. Volcano reads the default branch from GitHub through the project -owner's GitHub connection: if that connection needs reconnecting, or has lost -access to the repository, the command says so and nothing changes. A repository +branch that is. `volcano git status` shows which of the two applies. + +Volcano reads the default branch from GitHub through the project owner's GitHub +connection. If that connection needs reconnecting, or has lost access to the +repository, the command says so and nothing changes. If GitHub rate-limits the +read, the command says to try again shortly, and nothing changes. A repository with no commits has no default branch yet, so name a branch instead. This works with a project access token, so a CI job or an agent can change it. diff --git a/internal/api/git.go b/internal/api/git.go index c86dc2ad..7033297e 100644 --- a/internal/api/git.go +++ b/internal/api/git.go @@ -91,7 +91,7 @@ func (c *Client) SetProjectGitProductionBranch(ctx context.Context, projectID uu if err != nil { return nil, err } - return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON409, resp.JSON500) + return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON409, resp.JSON429, resp.JSON500) } // DisconnectProjectGit removes a project's repo connection. The repository diff --git a/internal/apiclient/client.gen.go b/internal/apiclient/client.gen.go index 758eb9b0..ef353c08 100644 --- a/internal/apiclient/client.gen.go +++ b/internal/apiclient/client.gen.go @@ -46777,6 +46777,7 @@ type SetProjectGitProductionBranchClientResponse struct { JSON403 *Error JSON404 *Error JSON409 *Error + JSON429 *Error JSON500 *Error } @@ -64151,6 +64152,13 @@ func ParseSetProjectGitProductionBranchClientResponse(rsp *http.Response) (*SetP } response.JSON409 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { diff --git a/internal/cmd/git/production_branch_test.go b/internal/cmd/git/production_branch_test.go index 1569a022..198fb576 100644 --- a/internal/cmd/git/production_branch_test.go +++ b/internal/cmd/git/production_branch_test.go @@ -134,7 +134,12 @@ func TestProductionBranchShowsTheServersRefusal(t *testing.T) { { []string{"--reset"}, http.StatusBadRequest, - "The repository has no default branch. Name a production branch, or add an initial commit and try again.", + "The repository has no commits yet, so there is no default branch to follow. Push a first commit, then try again.", + }, + { + []string{"--reset"}, + http.StatusTooManyRequests, + "GitHub rate limited reading the repository's default branch. Try again shortly.", }, { []string{"--reset"}, diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 14ee3fa6..2815900c 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -3111,8 +3111,10 @@ paths: '400': description: | Malformed request body, a production_branch that is not a valid - Git branch name, or an empty one for a repository with no default - branch yet + Git branch name, or an empty one for a repository with no commits + yet. Code `feature_unavailable` means this Volcano deployment has + no GitHub integration, so an empty production_branch cannot read + the default branch. content: application/json: schema: @@ -3148,6 +3150,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + '429': + description: GitHub rate limited reading the default branch for an empty production_branch. Try again shortly. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '500': description: Failed to set the production branch content: