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
75 changes: 59 additions & 16 deletions docs/git.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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.

Expand All @@ -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 <path>` |
| Change the production branch | `volcano git production-branch <branch>` |
| Disconnect | `volcano git disconnect` |

## Seeing what is connected
Expand All @@ -44,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
Expand Down Expand Up @@ -118,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
Expand All @@ -131,9 +138,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:
Expand Down Expand Up @@ -174,6 +182,40 @@ 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 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 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.

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
Expand Down Expand Up @@ -202,5 +244,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.
13 changes: 13 additions & 0 deletions internal/api/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.JSON429, 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 {
Expand Down
15 changes: 13 additions & 2 deletions internal/apiclient/client.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions internal/cmd/git/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
104 changes: 103 additions & 1 deletion internal/cmd/git/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions internal/cmd/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package git

import (
"context"
"errors"
"fmt"
"io"
Expand All @@ -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
}
Expand All @@ -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
Expand Down
Loading
Loading