From cf6c1b3648bcfb0e1659dc29ef7250cec83884f8 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:42:05 -0400 Subject: [PATCH 1/4] feat(git): change a connected repository's root directory Add `volcano git root-directory ` and `--reset`, which call PUT /projects/{id}/git-connection/root-directory. It changes only the directory the project builds from and accepts a project access token. `volcano git connect --root-directory` on the repository already bound now uses the same route instead of rebinding. With a project access token, connect names the root-directory command. --- docs/git.md | 37 ++++- internal/api/git.go | 12 ++ internal/apiclient/client.gen.go | 211 ++++++++++++++++++++++++ internal/cmd/git/connect.go | 73 +++++--- internal/cmd/git/connect_test.go | 51 +++++- internal/cmd/git/git.go | 1 + internal/cmd/git/helpers_test.go | 39 ++++- internal/cmd/git/root_directory.go | 108 ++++++++++++ internal/cmd/git/root_directory_test.go | 145 ++++++++++++++++ internal/gitconnect/gitconnect.go | 51 ++++++ internal/output/git.go | 14 ++ openapi/openapi.yaml | 102 +++++++++++- tests/e2e/localmode/git_test.go | 27 +++ tests/e2e/localmode/localmode_test.go | 1 + 14 files changed, 838 insertions(+), 34 deletions(-) create mode 100644 internal/cmd/git/root_directory.go create mode 100644 internal/cmd/git/root_directory_test.go create mode 100644 tests/e2e/localmode/git_test.go diff --git a/docs/git.md b/docs/git.md index 7e12043e..496b8048 100644 --- a/docs/git.md +++ b/docs/git.md @@ -20,7 +20,11 @@ Pushing stays your own `git push`, with the credentials already on your machine. Volcano GitHub App installed with access to the repository. Both are set up in the dashboard, not from the CLI. - `connect` reads that account-wide GitHub connection, so it needs an account - token (`pk-`) and says so when run with a project access token. + 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. - What a push actually deploys is governed by the project's Git deploy settings, reported after a successful connect. @@ -30,6 +34,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 ` | | Disconnect | `volcano git disconnect` | ## Seeing what is connected @@ -118,8 +123,7 @@ branch has moved is *not* unchanged, and re-running connect updates the branch a push has to land on. 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, which is the only way to change it -after the first connect. Pointing it at a *different* repository asks for +different `--root-directory` edits that, as `volcano git root-directory` does. Pointing it at a *different* repository asks for confirmation first, because pushes to the current one stop deploying. `--yes` skips the prompts. @@ -143,6 +147,31 @@ checkout `git push origin` goes to the repository you *fetch* from, which is not the one connected and deploys nothing — `volcano git status` reports which repository is bound. +## Changing the root directory + +For a monorepo, change the subdirectory the project builds from without +changing which repository is connected: + +```bash +volcano git root-directory apps/web +volcano git root-directory --reset # back to the repository root +``` + +The path is relative to the repository root. An absolute path, or one climbing +out with `..`, is refused. Volcano stores it in the form a deploy reads it, so +`./apps/web/` becomes `apps/web`. + +This works with a project access token, so a CI job or an agent can move a +project within its repository. Connecting a different repository needs a +person's account token. With a project access token, `volcano git connect +--root-directory` points to this command instead. + +Nothing is redeployed. The next push to the production branch builds from the +new directory. + +A project with no repository connected has no root directory to change, so the +command says so and exits non-zero. + ## Disconnecting ```bash @@ -171,3 +200,5 @@ 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. diff --git a/internal/api/git.go b/internal/api/git.go index a56b564c..5fde83ad 100644 --- a/internal/api/git.go +++ b/internal/api/git.go @@ -69,6 +69,18 @@ func (c *Client) ConnectProjectGit(ctx context.Context, projectID uuid.UUID, bod return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON500, resp.JSON503) } +// SetProjectGitRootDirectory changes only the directory a connected project +// builds from, leaving the repository and production branch alone. Unlike +// ConnectProjectGit it accepts a project access token. +func (c *Client) SetProjectGitRootDirectory(ctx context.Context, projectID uuid.UUID, rootDirectory string) (*apiclient.ProjectGitConnection, error) { + resp, err := c.client.SetProjectGitRootDirectoryWithResponse(ctx, projectID, + apiclient.SetProjectGitRootDirectoryJSONRequestBody{RootDirectory: rootDirectory}) + 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 5ab9320e..d5dc5adb 100644 --- a/internal/apiclient/client.gen.go +++ b/internal/apiclient/client.gen.go @@ -10216,6 +10216,12 @@ type SetProjectGitProductionBranchRequest struct { ProductionBranch string `json:"production_branch"` } +// SetProjectGitRootDirectoryRequest defines model for SetProjectGitRootDirectoryRequest. +type SetProjectGitRootDirectoryRequest struct { + // RootDirectory Monorepo subdirectory the project builds from, relative to the repository root. An empty string means the repository root. + RootDirectory string `json:"root_directory"` +} + // StorageBucket A named container for files within a project. // Public access is controlled at the file level via is_public on StorageObject. type StorageBucket struct { @@ -13099,6 +13105,9 @@ type ConnectProjectGitJSONRequestBody = ConnectProjectGitRequest // SetProjectGitProductionBranchJSONRequestBody defines body for SetProjectGitProductionBranch for application/json ContentType. type SetProjectGitProductionBranchJSONRequestBody = SetProjectGitProductionBranchRequest +// SetProjectGitRootDirectoryJSONRequestBody defines body for SetProjectGitRootDirectory for application/json ContentType. +type SetProjectGitRootDirectoryJSONRequestBody = SetProjectGitRootDirectoryRequest + // UpdateProjectGitDeploySettingsJSONRequestBody defines body for UpdateProjectGitDeploySettings for application/json ContentType. type UpdateProjectGitDeploySettingsJSONRequestBody = UpdateProjectGitDeploySettingsRequest @@ -15345,6 +15354,11 @@ type ClientInterface interface { SetProjectGitProductionBranch(ctx context.Context, id ProjectId, body SetProjectGitProductionBranchJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // SetProjectGitRootDirectoryWithBody request with any body + SetProjectGitRootDirectoryWithBody(ctx context.Context, id ProjectId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetProjectGitRootDirectory(ctx context.Context, id ProjectId, body SetProjectGitRootDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetProjectGitDeploySettings request GetProjectGitDeploySettings(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -19282,6 +19296,30 @@ func (c *Client) SetProjectGitProductionBranch(ctx context.Context, id ProjectId return c.Client.Do(req) } +func (c *Client) SetProjectGitRootDirectoryWithBody(ctx context.Context, id ProjectId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetProjectGitRootDirectoryRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetProjectGitRootDirectory(ctx context.Context, id ProjectId, body SetProjectGitRootDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetProjectGitRootDirectoryRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetProjectGitDeploySettings(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetProjectGitDeploySettingsRequest(c.Server, id) if err != nil { @@ -32611,6 +32649,53 @@ func NewSetProjectGitProductionBranchRequestWithBody(server string, id ProjectId return req, nil } +// NewSetProjectGitRootDirectoryRequest calls the generic SetProjectGitRootDirectory builder with application/json body +func NewSetProjectGitRootDirectoryRequest(server string, id ProjectId, body SetProjectGitRootDirectoryJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetProjectGitRootDirectoryRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewSetProjectGitRootDirectoryRequestWithBody generates requests for SetProjectGitRootDirectory with any type of body +func NewSetProjectGitRootDirectoryRequestWithBody(server string, id ProjectId, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/git-connection/root-directory", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewGetProjectGitDeploySettingsRequest generates requests for GetProjectGitDeploySettings func NewGetProjectGitDeploySettingsRequest(server string, id ProjectId) (*http.Request, error) { var err error @@ -38507,6 +38592,11 @@ type ClientWithResponsesInterface interface { SetProjectGitProductionBranchWithResponse(ctx context.Context, id ProjectId, body SetProjectGitProductionBranchJSONRequestBody, reqEditors ...RequestEditorFn) (*SetProjectGitProductionBranchClientResponse, error) + // SetProjectGitRootDirectoryWithBodyWithResponse request with any body + SetProjectGitRootDirectoryWithBodyWithResponse(ctx context.Context, id ProjectId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetProjectGitRootDirectoryClientResponse, error) + + SetProjectGitRootDirectoryWithResponse(ctx context.Context, id ProjectId, body SetProjectGitRootDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*SetProjectGitRootDirectoryClientResponse, error) + // GetProjectGitDeploySettingsWithResponse request GetProjectGitDeploySettingsWithResponse(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*GetProjectGitDeploySettingsClientResponse, error) @@ -46208,6 +46298,42 @@ func (r SetProjectGitProductionBranchClientResponse) ContentType() string { return "" } +type SetProjectGitRootDirectoryClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ProjectGitConnection + JSON400 *Error + JSON401 *Error + JSON403 *Error + JSON404 *Error + JSON409 *Error + JSON500 *Error +} + +// Status returns HTTPResponse.Status +func (r SetProjectGitRootDirectoryClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetProjectGitRootDirectoryClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetProjectGitRootDirectoryClientResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type GetProjectGitDeploySettingsClientResponse struct { Body []byte HTTPResponse *http.Response @@ -51951,6 +52077,23 @@ func (c *ClientWithResponses) SetProjectGitProductionBranchWithResponse(ctx cont return ParseSetProjectGitProductionBranchClientResponse(rsp) } +// SetProjectGitRootDirectoryWithBodyWithResponse request with arbitrary body returning *SetProjectGitRootDirectoryClientResponse +func (c *ClientWithResponses) SetProjectGitRootDirectoryWithBodyWithResponse(ctx context.Context, id ProjectId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetProjectGitRootDirectoryClientResponse, error) { + rsp, err := c.SetProjectGitRootDirectoryWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetProjectGitRootDirectoryClientResponse(rsp) +} + +func (c *ClientWithResponses) SetProjectGitRootDirectoryWithResponse(ctx context.Context, id ProjectId, body SetProjectGitRootDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*SetProjectGitRootDirectoryClientResponse, error) { + rsp, err := c.SetProjectGitRootDirectory(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetProjectGitRootDirectoryClientResponse(rsp) +} + // GetProjectGitDeploySettingsWithResponse request returning *GetProjectGitDeploySettingsClientResponse func (c *ClientWithResponses) GetProjectGitDeploySettingsWithResponse(ctx context.Context, id ProjectId, reqEditors ...RequestEditorFn) (*GetProjectGitDeploySettingsClientResponse, error) { rsp, err := c.GetProjectGitDeploySettings(ctx, id, reqEditors...) @@ -63225,6 +63368,74 @@ func ParseSetProjectGitProductionBranchClientResponse(rsp *http.Response) (*SetP return response, nil } +// ParseSetProjectGitRootDirectoryClientResponse parses an HTTP response from a SetProjectGitRootDirectoryWithResponse call +func ParseSetProjectGitRootDirectoryClientResponse(rsp *http.Response) (*SetProjectGitRootDirectoryClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetProjectGitRootDirectoryClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ProjectGitConnection + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseGetProjectGitDeploySettingsClientResponse parses an HTTP response from a GetProjectGitDeploySettingsWithResponse call func ParseGetProjectGitDeploySettingsClientResponse(rsp *http.Response) (*GetProjectGitDeploySettingsClientResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/internal/cmd/git/connect.go b/internal/cmd/git/connect.go index 432b972a..7c55e8c9 100644 --- a/internal/cmd/git/connect.go +++ b/internal/cmd/git/connect.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/Kong/volcano-cli/internal/apiclient" + "github.com/Kong/volcano-cli/internal/config" "github.com/Kong/volcano-cli/internal/gitconnect" "github.com/Kong/volcano-cli/internal/localgit" "github.com/Kong/volcano-cli/internal/output" @@ -109,8 +110,10 @@ func runConnect(ctx context.Context, opts connectOptions) error { // below still reports what went wrong, so it is not worth failing over. webURL, _ := service.WebURL() - if err := validateRootDirectory(opts); err != nil { - return err + if opts.rootDirectorySet { + if err := validateRootDirectory("--root-directory", opts.rootDirectory); err != nil { + return err + } } repository, selected, err := resolveRepository(ctx, opts) @@ -164,6 +167,12 @@ func runConnect(ctx context.Context, opts connectOptions) error { // binding the CLI has not established it can make. target, err := service.Resolve(ctx, repository) if err != nil { + // Resolving reads the account's GitHub connections, which a project + // access token may not. The root directory alone has a route it may use. + if errors.Is(err, config.ErrAccountTokenRequired) && opts.rootDirectorySet && existing != nil { + return fmt.Errorf("%w\n\nTo change only the root directory of the connected repository with this token, run:\n %s", + err, rootDirectoryCommand(opts.deps, opts.rootDirectory)) + } return resolveError(opts.deps, webURL, existing, repository, err) } @@ -204,7 +213,14 @@ func runConnect(ctx context.Context, opts connectOptions) error { } } - connection, err := service.Connect(ctx, *target, rootDirectoryFor(existing, target, opts), existing) + // Only the root directory differs on the repository already bound, so the + // narrower write makes the change: it cannot touch the repository. + var connection *apiclient.ProjectGitConnection + if sameRepositoryBinding(existing, target) { + connection, err = service.SetRootDirectory(ctx, opts.rootDirectory, existing) + } else { + connection, err = service.Connect(ctx, *target, rootDirectoryFor(existing, target, opts), existing) + } if err != nil { return guide(opts.deps, webURL, err) } @@ -232,22 +248,23 @@ func runConnect(ctx context.Context, opts connectOptions) error { return nil } -// validateRootDirectory refuses what the flag cannot mean. The platform builds -// from this path inside the repository, so an absolute path or one climbing out -// of it deploys nothing — and the CLI is what reports success, so it should not -// report it for a value that cannot work. -func validateRootDirectory(opts connectOptions) error { - if !opts.rootDirectorySet || opts.rootDirectory == "" { +// validateRootDirectory refuses a root directory, named by what in the command +// gave it, that cannot mean anything. The platform builds from this path inside +// the repository, so an absolute path or one climbing out of it deploys nothing +// — and the CLI is what reports success, so it should not report it for a value +// that cannot work. Empty is the repository root. +func validateRootDirectory(name, rootDirectory string) error { + if rootDirectory == "" { return nil } - root := filepath.ToSlash(opts.rootDirectory) - if strings.HasPrefix(root, "/") || filepath.IsAbs(opts.rootDirectory) { - return fmt.Errorf("--root-directory must be a path inside the repository, not an absolute path: %s", root) + root := filepath.ToSlash(rootDirectory) + if strings.HasPrefix(root, "/") || filepath.IsAbs(rootDirectory) { + return fmt.Errorf("%s must be a path inside the repository, not an absolute path: %s", name, root) } for segment := range strings.SplitSeq(root, "/") { if segment == ".." { - return fmt.Errorf("--root-directory must stay inside the repository, so it cannot contain %q: %s", "..", root) + return fmt.Errorf("%s must stay inside the repository, so it cannot contain %q: %s", name, "..", root) } } return nil @@ -334,9 +351,20 @@ func currentConnection(ctx context.Context, service gitconnect.Service) (*apicli // there is nothing to send. The name has to match as well as the id: an id match // under a stale cached name means the repository was renamed, and rebinding is // what refreshes that name. A root directory the user named explicitly counts -// too — keying only on the repository would drop --root-directory in silence, -// and this bind is the only way to edit it. +// too — keying only on the repository would drop --root-directory in silence. func unchanged(existing *apiclient.ProjectGitConnection, target *gitconnect.Target, opts connectOptions) bool { + if !sameRepositoryBinding(existing, target) { + return false + } + if !opts.rootDirectorySet { + return true + } + return opts.rootDirectory == strings.TrimSpace(existing.RootDirectory) +} + +// sameRepositoryBinding reports whether the project is bound to target exactly, +// apart from the root directory. +func sameRepositoryBinding(existing *apiclient.ProjectGitConnection, target *gitconnect.Target) bool { if existing == nil || existing.RepoId != target.Repository.Id { return false } @@ -354,13 +382,16 @@ func unchanged(existing *apiclient.ProjectGitConnection, target *gitconnect.Targ // 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 // is exactly the one an operator needs told, not reported as unchanged. - if existing.ProductionBranch != target.Repository.DefaultBranch { - return false - } - if !opts.rootDirectorySet { - return true + return existing.ProductionBranch == target.Repository.DefaultBranch +} + +// rootDirectoryCommand is the root-directory command that sets rootDirectory, +// ready to paste. +func rootDirectoryCommand(deps cliruntime.Deps, rootDirectory string) string { + if rootDirectory == "" { + return cliruntime.CommandPath(deps, "git root-directory --reset") } - return opts.rootDirectory == strings.TrimSpace(existing.RootDirectory) + return cliruntime.CommandPath(deps, "git root-directory "+cliruntime.ShellQuote(rootDirectory)) } // rootDirectoryFor decides what the full-replace bind carries. An omitted flag diff --git a/internal/cmd/git/connect_test.go b/internal/cmd/git/connect_test.go index 8fe075c0..97b153e1 100644 --- a/internal/cmd/git/connect_test.go +++ b/internal/cmd/git/connect_test.go @@ -368,8 +368,9 @@ func TestConnectAsksOnceWhenReplacingAnExplicitRepository(t *testing.T) { assert.Contains(t, out, "Connected octo/storefront") } -// --root-directory is the only way to set or change the subdirectory a project -// builds from, so an already-connected repository must not short-circuit it. +// An already-connected repository must not short-circuit --root-directory. +// Only the root directory changes, so it goes through the route that changes +// nothing else, not a rebind of the repository. func TestConnectUpdatesTheRootDirectoryOnAConnectedRepository(t *testing.T) { setGitCommandTestHome(t) api := newGitAPI(t) @@ -379,8 +380,8 @@ func TestConnectUpdatesTheRootDirectoryOnAConnectedRepository(t *testing.T) { "", "connect", "--root-directory", "apps/api") require.NoError(t, err) - require.NotNil(t, api.sentConnectBody(), "a root-directory change must be sent") - assert.Equal(t, "apps/api", api.sentConnectBody()["root_directory"]) + assert.Equal(t, map[string]any{"root_directory": "apps/api"}, api.sentRootBody()) + assert.Nil(t, api.sentConnectBody(), "the repository is not rebound") assert.Contains(t, out, "Root directory: apps/api") // Nothing was replaced: it is the same repository. assert.NotContains(t, out, "Replacing the existing connection") @@ -395,8 +396,47 @@ func TestConnectClearsTheRootDirectoryWhenExplicitlyEmptied(t *testing.T) { _, err := executeGitCommand(t, api.serve(), &gitRunner{stdout: originRemoteOutput}, "", "connect", "--root-directory", "") require.NoError(t, err) + assert.Equal(t, map[string]any{"root_directory": ""}, api.sentRootBody()) + assert.Nil(t, api.sentConnectBody()) +} + +// A rename or a moved default branch needs the full bind to refresh what was +// recorded, so a root directory asked for alongside it rides on that bind. +func TestConnectRebindsWhenMoreThanTheRootDirectoryChanged(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.connectedBranch = "master" + + _, err := executeGitCommand(t, api.serve(), &gitRunner{stdout: originRemoteOutput}, + "", "connect", "--root-directory", "apps/api") + require.NoError(t, err) require.NotNil(t, api.sentConnectBody()) - assert.NotContains(t, api.sentConnectBody(), "root_directory") + assert.Equal(t, "apps/api", api.sentConnectBody()["root_directory"]) + assert.Nil(t, api.sentRootBody()) +} + +// A project access token cannot resolve a repository, which needs the +// account's GitHub connection, but it can change the root directory: connect +// says how. +func TestConnectWithAProjectTokenPointsAtTheRootDirectoryCommand(t *testing.T) { + setGitCommandTestHome(t) + setGitCommandTestToken(t, cliconfig.ProjectTokenPrefix+"token") + api := newGitAPI(t) + api.connected = "octo/storefront" + + for root, command := range map[string]string{ + "apps/api": "volcano git root-directory apps/api", + "apps/my x": "volcano git root-directory 'apps/my x'", + "": "volcano git root-directory --reset", + } { + _, err := executeGitCommand(t, api.serve(), &gitRunner{stdout: originRemoteOutput}, + "", "connect", "--root-directory", root) + require.ErrorIs(t, err, cliconfig.ErrAccountTokenRequired) + assert.Contains(t, err.Error(), "To change only the root directory of the connected repository with this token, run:\n "+command) + } + assert.Nil(t, api.sentConnectBody()) + assert.Nil(t, api.sentRootBody()) } func TestConnectLeavesAnUnmentionedRootDirectoryAlone(t *testing.T) { @@ -863,6 +903,7 @@ func TestConnectRefusesWhenOnlyTheRootDirectoryMovedUnderIt(t *testing.T) { assert.Contains(t, err.Error(), "changed while this command was running") assert.Nil(t, api.sentConnectBody(), "apps/new must not be overwritten") + assert.Nil(t, api.sentRootBody(), "apps/new must not be overwritten") } // Nothing is written when the binding already matches, so a concurrent edit diff --git a/internal/cmd/git/git.go b/internal/cmd/git/git.go index e82600c0..2b9cafc4 100644 --- a/internal/cmd/git/git.go +++ b/internal/cmd/git/git.go @@ -27,6 +27,7 @@ your machine.`, } cmd.AddCommand(newStatus(deps)) cmd.AddCommand(newConnect(deps)) + cmd.AddCommand(newRootDirectory(deps)) cmd.AddCommand(newDisconnect(deps)) return cmd } diff --git a/internal/cmd/git/helpers_test.go b/internal/cmd/git/helpers_test.go index 3f8833a3..5c1d7ab9 100644 --- a/internal/cmd/git/helpers_test.go +++ b/internal/cmd/git/helpers_test.go @@ -190,8 +190,14 @@ type gitAPI struct { // commands tolerate without failing the connect. deploySettingsStatus int - mu sync.Mutex - connectBody map[string]any + // rootStatus and rootMessage make the root-directory route refuse. + rootStatus int + rootMessage string + + mu sync.Mutex + connectBody map[string]any + // rootBody is what the root-directory route was sent. + rootBody map[string]any deleted bool connectionReads int // accountReads counts the calls to the caller's own GitHub routes, which @@ -251,6 +257,8 @@ func (a *gitAPI) handle(w http.ResponseWriter, r *http.Request) { a.serveConnection(w) case r.Method == http.MethodPut && strings.HasSuffix(r.URL.Path, "/git-connection"): 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.MethodDelete && strings.HasSuffix(r.URL.Path, "/git-connection"): a.serveDisconnect(w) case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/git-deploy-settings"): @@ -410,6 +418,27 @@ func (a *gitAPI) serveConnect(w http.ResponseWriter, r *http.Request) { connectionPayload(a.connected, a.connectedRoot, a.connectedRepoID, a.currentInstallation())) } +// serveRootDirectory changes only the binding's root directory, answering 404 +// for a project with none, as the platform does. +func (a *gitAPI) serveRootDirectory(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + require.NoError(a.t, json.NewDecoder(r.Body).Decode(&a.rootBody)) + + if a.rootStatus != 0 { + writeGitJSON(a.t, w, a.rootStatus, map[string]any{"error": a.rootMessage}) + return + } + if a.connected == "" { + writeGitJSON(a.t, w, http.StatusNotFound, map[string]any{"error": "project has no repository connected"}) + return + } + 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())) +} + func (a *gitAPI) serveDisconnect(w http.ResponseWriter) { a.mu.Lock() defer a.mu.Unlock() @@ -443,6 +472,12 @@ func (a *gitAPI) sentConnectBody() map[string]any { return a.connectBody } +func (a *gitAPI) sentRootBody() map[string]any { + a.mu.Lock() + defer a.mu.Unlock() + return a.rootBody +} + func (a *gitAPI) accountRouteReads() int { a.mu.Lock() defer a.mu.Unlock() diff --git a/internal/cmd/git/root_directory.go b/internal/cmd/git/root_directory.go new file mode 100644 index 00000000..90d27341 --- /dev/null +++ b/internal/cmd/git/root_directory.go @@ -0,0 +1,108 @@ +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 rootDirectoryOptions struct { + deps cliruntime.Deps + // rootDirectory is the directory to build from, "" for the repository + // root. + rootDirectory string + out io.Writer +} + +func newRootDirectory(deps cliruntime.Deps) *cobra.Command { + var reset bool + cmd := &cobra.Command{ + Use: "root-directory [path]", + Short: "Change the subdirectory the connected repository builds from", + Long: fmt.Sprintf(`Change the subdirectory of the connected repository that the project builds +from. The repository stays connected as it is. + +The path is relative to the repository root, such as apps/web. Pass --reset to +build from the repository root again. + +This works with a project access token, so CI or an agent can move a project +within its repository. Connecting a different repository needs a person's +account token: see '%s'. + +Nothing is redeployed. The next push to the production branch builds from the +new directory.`, cliruntime.CommandPath(deps, "git connect")), + Example: " volcano git root-directory apps/web\n volcano git root-directory --reset", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + rootDirectory, err := rootDirectoryArgument(args, reset) + if err != nil { + return err + } + return runRootDirectory(cmd.Context(), rootDirectoryOptions{ + deps: deps, + rootDirectory: rootDirectory, + out: cmd.OutOrStdout(), + }) + }, + } + cmd.Flags().BoolVar(&reset, "reset", false, "Build from the repository root") + return cmd +} + +// rootDirectoryArgument is the directory asked for. Resetting to the +// repository root takes --reset rather than an empty argument, which is what an +// unset variable in a script looks like. +func rootDirectoryArgument(args []string, reset bool) (string, error) { + switch { + case reset && len(args) == 1: + return "", errors.New("pass a directory or --reset, not both") + case reset: + return "", nil + case len(args) == 0: + return "", errors.New("name the directory the project builds from, such as apps/web, " + + "or pass --reset to build from the repository root") + } + rootDirectory := strings.TrimSpace(args[0]) + if rootDirectory == "" { + return "", errors.New("the directory is empty; pass --reset to build from the repository root") + } + if err := validateRootDirectory("the root directory", rootDirectory); err != nil { + return "", err + } + return rootDirectory, nil +} + +func runRootDirectory(ctx context.Context, opts rootDirectoryOptions) error { + service := gitconnect.NewService(opts.deps) + webURL, _ := service.WebURL() + + project, err := service.Project() + if err != nil { + return err + } + + // Nothing is read first: the write names only the root directory, so it + // cannot replace a repository someone else connected meanwhile, and it + // 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) + } + + output.GitRootDirectorySet(opts.out, output.GitBinding{Connection: connection, Project: project.Label()}) + return nil +} diff --git a/internal/cmd/git/root_directory_test.go b/internal/cmd/git/root_directory_test.go new file mode 100644 index 00000000..875f970f --- /dev/null +++ b/internal/cmd/git/root_directory_test.go @@ -0,0 +1,145 @@ +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 TestRootDirectorySetsOnlyTheRootDirectory(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + + out, err := executeGitCommand(t, api.serve(), nil, "", "root-directory", "apps/web") + require.NoError(t, err) + + assert.Equal(t, map[string]any{"root_directory": "apps/web"}, api.sentRootBody()) + assert.Nil(t, api.sentConnectBody(), "the repository is not rebound") + assert.Zero(t, api.accountRouteReads(), "nothing is resolved on GitHub") + assert.Contains(t, out, "Root directory set to apps/web") + assert.Contains(t, out, "Repository: octo/storefront") + assert.Contains(t, out, "Nothing was redeployed. The next push to main builds from this directory.") +} + +func TestRootDirectoryResetsToTheRepositoryRoot(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.connectedRoot = "apps/web" + + out, err := executeGitCommand(t, api.serve(), nil, "", "root-directory", "--reset") + require.NoError(t, err) + + assert.Equal(t, map[string]any{"root_directory": ""}, api.sentRootBody()) + assert.Contains(t, out, "Root directory reset to the repository root") + assert.NotContains(t, out, "Root directory: ") +} + +// CI and agents hold project access tokens, which this route accepts. +func TestRootDirectoryWorksWithAProjectAccessToken(t *testing.T) { + setGitCommandTestHome(t) + setGitCommandTestToken(t, cliconfig.ProjectTokenPrefix+"token") + api := newGitAPI(t) + api.connected = "octo/storefront" + + _, err := executeGitCommand(t, api.serve(), nil, "", "root-directory", "apps/web") + require.NoError(t, err) + assert.Equal(t, map[string]any{"root_directory": "apps/web"}, api.sentRootBody()) +} + +func TestRootDirectoryRefusesWhatCannotMeanADirectory(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"root-directory"}, "name the directory the project builds from, such as apps/web, or pass --reset"}, + {[]string{"root-directory", ""}, "the directory is empty; pass --reset to build from the repository root"}, + {[]string{"root-directory", " "}, "the directory is empty; pass --reset to build from the repository root"}, + {[]string{"root-directory", "apps/web", "--reset"}, "pass a directory or --reset, not both"}, + {[]string{"root-directory", "/srv/app"}, "the root directory must be a path inside the repository, not an absolute path: /srv/app"}, + {[]string{"root-directory", "../outside"}, `the root directory must stay inside the repository, so it cannot contain "..": ../outside`}, + } { + 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.sentRootBody(), "%q", tc.args) + } +} + +// The server has the last word on a directory, and its reason is shown as it +// gave it. +func TestRootDirectoryShowsTheServersRefusal(t *testing.T) { + for status, message := range map[int]string{ + http.StatusBadRequest: "root directory must be a relative path inside the repository", + http.StatusConflict: "the root directory can't be changed while the project's Git source is being handed over", + http.StatusForbidden: "project access token is read-only", + } { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.rootStatus, api.rootMessage = status, message + + _, err := executeGitCommand(t, api.serve(), nil, "", "root-directory", "apps/web") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to set the root directory: ") + assert.Contains(t, err.Error(), message) + } +} + +func TestRootDirectoryOnAProjectWithNothingConnected(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + + _, err := executeGitCommand(t, api.serve(), nil, "", "root-directory", "apps/web") + 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 TestRootDirectoryWhereNoRepositoryCanBeConnected(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.providerStatus = http.StatusServiceUnavailable + + _, err := executeGitCommand(t, api.serve(), nil, "", "root-directory", "apps/web") + 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") +} + +// A project access token may not read the account's GitHub connections, so it +// cannot tell, and gets the plain answer. +func TestRootDirectoryWithAProjectTokenOnAProjectWithNothingConnected(t *testing.T) { + setGitCommandTestHome(t) + setGitCommandTestToken(t, cliconfig.ProjectTokenPrefix+"token") + api := newGitAPI(t) + api.providerStatus = http.StatusServiceUnavailable + + _, err := executeGitCommand(t, api.serve(), nil, "", "root-directory", "apps/web") + require.Error(t, err) + assert.Contains(t, err.Error(), "this project is not connected to a repository") + assert.Zero(t, api.accountRouteReads()) +} + +func TestRootDirectoryReportsAMissingProject(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.projectMissing = true + + _, err := executeGitCommand(t, api.serve(), nil, "", "root-directory", "apps/web") + 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/gitconnect/gitconnect.go b/internal/gitconnect/gitconnect.go index 8d4482cb..9c184a7e 100644 --- a/internal/gitconnect/gitconnect.go +++ b/internal/gitconnect/gitconnect.go @@ -323,6 +323,57 @@ func (s Service) Connect( return connection, nil } +// SetRootDirectory changes only the directory the project builds from, leaving +// the repository bound alone. It is the one change to a binding a project +// access token may make, so CI and agents can move a project within the +// repository a person chose. +// +// When expected is not nil, the binding must still be the one it describes, as +// for Connect: a command that read the binding before resolving the repository +// must not overwrite a root directory someone changed in the meantime. A nil +// expected skips that read, for a caller that read nothing first. +func (s Service) SetRootDirectory( + ctx context.Context, rootDirectory string, expected *apiclient.ProjectGitConnection, +) (*apiclient.ProjectGitConnection, error) { + authenticated, err := s.current() + if err != nil { + return nil, err + } + + if expected != nil { + if err := s.confirmUnchanged(ctx, expected); err != nil { + return nil, err + } + } + + connection, err := authenticated.API.SetProjectGitRootDirectory(ctx, authenticated.ProjectID, rootDirectory) + 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 root directory") + } + 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 +// nothing connected. It answers nil whenever it cannot tell, including for a +// project access token, which may not read the account's GitHub connections. +func (s Service) CanConnect(ctx context.Context) error { + authenticated, err := s.current() + if err != nil || authenticated.Config.RequireAccountToken() != nil { + return nil //nolint:nilerr // unknown is not a refusal + } + if _, err := authenticated.API.ListGitConnections(ctx); api.Status(err) == http.StatusServiceUnavailable { + return ErrProviderNotConfigured + } + return nil +} + // Disconnect removes the project's repository binding, provided it is still the // one in expected. The repository itself is untouched. // diff --git a/internal/output/git.go b/internal/output/git.go index f66daf89..f8a312af 100644 --- a/internal/output/git.go +++ b/internal/output/git.go @@ -68,6 +68,20 @@ func GitNotConnected(w io.Writer, project, connectCommand string) { fmt.Fprintf(w, "\n%s%s\n", theme.Dim("Connect one with ", on), theme.Command(connectCommand, on)) } +// GitRootDirectorySet renders a root directory that was just set. Nothing is +// redeployed by it, which is worth saying: the change only shows on the next +// push. +func GitRootDirectorySet(w io.Writer, binding GitBinding) { + connection := binding.Connection + if root := strings.TrimSpace(connection.RootDirectory); root != "" { + Success(w, "Root directory set to %s", root) + } else { + Success(w, "Root directory reset to the repository root") + } + gitBindingDetail(w, theme.On(w), binding) + Note(w, "Nothing was redeployed. The next push to %s builds from this directory.", connection.ProductionBranch) +} + // 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 8172e0bb..3f98c144 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -3142,6 +3142,83 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /projects/{id}/git-connection/root-directory: + put: + tags: + - Projects + - 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 + project access token, so CI or an agent can move a project within its + repository without being able to change the repository. + + The directory is a path relative to the repository root. An empty + string, `.` or `/` means the repository root. The response carries it in + the form a deploy reads it: `./apps/web/` comes back as `apps/web`. + operationId: setProjectGitRootDirectory + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SetProjectGitRootDirectoryRequest' + responses: + '200': + description: The project's repo connection, with the new root directory + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectGitConnection' + '400': + description: | + Malformed request body, or a root_directory that is absolute or + leaves the repository + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized - invalid or missing token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Project not owned by the caller + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: | + Project not found, or it has no repository connected. The root + directory is part of the connection, so there is nothing to set it + on until one exists. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + A Git source transition is pending or complete, so the recorded + root cannot be changed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Failed to set the root directory + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /projects/{id}/git-connection: get: tags: @@ -3199,10 +3276,16 @@ paths: accessible through installation_id via connection_id's stored GitHub user token, then persists repository metadata only from that validated GitHub response. + + 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 + and production branch through PUT + /projects/{id}/git-connection/root-directory and PUT + /projects/{id}/git-connection/production-branch. operationId: connectProjectGit security: - UserToken: [] - - ProjectAccessToken: [] parameters: - $ref: '#/components/parameters/ProjectId' requestBody: @@ -3238,8 +3321,12 @@ paths: $ref: '#/components/schemas/Error' '403': description: | - Project not owned by the caller, or the selected repository is not - accessible through installation_id + Project not owned by the caller, the selected repository is not + accessible through installation_id, or the request used a project + access token. A full-scope token gets `project access tokens cannot + change a project's repository; a person changes it in the dashboard + or with a platform token`, and a read-only token gets `project + access token is read-only`. content: application/json: schema: @@ -20261,6 +20348,15 @@ components: description: The branch a push must land on to deploy. Validated as a Git branch name only — it does not have to exist yet. required: - production_branch + SetProjectGitRootDirectoryRequest: + type: object + properties: + root_directory: + type: string + maxLength: 1024 + description: Monorepo subdirectory the project builds from, relative to the repository root. An empty string means the repository root. + required: + - root_directory ConnectProjectGitRequest: type: object properties: diff --git a/tests/e2e/localmode/git_test.go b/tests/e2e/localmode/git_test.go new file mode 100644 index 00000000..a45c8ab0 --- /dev/null +++ b/tests/e2e/localmode/git_test.go @@ -0,0 +1,27 @@ +package localmode + +import ( + "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. +func requireLocalModeLeavesGitToTheCloud(t *testing.T, binary string, env []string, dir string) { + t.Helper() + info := fetchVolcanoLocalModeE2EInfo(t, env) + local := append(append([]string{}, env...), + "VOLCANO_API_URL="+info.APIURL, + "VOLCANO_TOKEN="+info.UserToken, + "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) + } + 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") +} diff --git a/tests/e2e/localmode/localmode_test.go b/tests/e2e/localmode/localmode_test.go index 35525478..e442afb1 100644 --- a/tests/e2e/localmode/localmode_test.go +++ b/tests/e2e/localmode/localmode_test.go @@ -60,6 +60,7 @@ func TestLocalModeE2ESmoke(t *testing.T) { requireLocalModeLeavesFrontendDomainsToTheCloud(t, volcanoBin, env, projectDir) requireLocalModeLeavesBuildReuseToTheCloud(t, volcanoBin, env, projectDir) requireLocalModeOmitsDomainCommands(t, volcanoBin, env, projectDir) + requireLocalModeLeavesGitToTheCloud(t, volcanoBin, env, projectDir) requireLocalModeSendsFeedback(t, volcanoBin, env, projectDir) requireLocalModeSendsNotifications(t, volcanoBin, env, projectDir) requireLocalModeServesFrontends(t, volcanoBin, env, projectDir) From 0177c6c7f0b252d2cf4c38a53043bd9d44746564 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:53:46 -0400 Subject: [PATCH 2/4] fix(git): say what a push deploys after changing the root directory --- docs/git.md | 5 +++-- internal/cmd/git/root_directory.go | 15 ++++++++++--- internal/cmd/git/root_directory_test.go | 30 ++++++++++++++++++++++++- internal/output/git.go | 10 +++++---- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/git.md b/docs/git.md index 496b8048..d86adbd3 100644 --- a/docs/git.md +++ b/docs/git.md @@ -166,8 +166,9 @@ project within its repository. Connecting a different repository needs a person's account token. With a project access token, `volcano git connect --root-directory` points to this command instead. -Nothing is redeployed. The next push to the production branch builds from the -new directory. +Nothing is redeployed. The next deploy from the repository builds from the new +directory. 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 root directory to change, so the command says so and exits non-zero. diff --git a/internal/cmd/git/root_directory.go b/internal/cmd/git/root_directory.go index 90d27341..f5e428ac 100644 --- a/internal/cmd/git/root_directory.go +++ b/internal/cmd/git/root_directory.go @@ -37,8 +37,9 @@ This works with a project access token, so CI or an agent can move a project within its repository. Connecting a different repository needs a person's account token: see '%s'. -Nothing is redeployed. The next push to the production branch builds from the -new directory.`, cliruntime.CommandPath(deps, "git connect")), +Nothing is redeployed. The next deploy from the repository builds from the new +directory, and the command says what a push to the production branch deploys.`, + cliruntime.CommandPath(deps, "git connect")), Example: " volcano git root-directory apps/web\n volcano git root-directory --reset", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -103,6 +104,14 @@ func runRootDirectory(ctx context.Context, opts rootDirectoryOptions) error { return guide(opts.deps, webURL, err) } - output.GitRootDirectorySet(opts.out, output.GitBinding{Connection: connection, Project: project.Label()}) + // Read afterwards, like connect: the directory is set either way, and + // only these say whether a push deploys anything from it. + settings, settingsErr := service.DeploySettings(ctx) + output.GitRootDirectorySet(opts.out, output.GitBinding{ + Connection: connection, + Settings: settings, + SettingsErr: settingsErr, + Project: project.Label(), + }) return nil } diff --git a/internal/cmd/git/root_directory_test.go b/internal/cmd/git/root_directory_test.go index 875f970f..b5bae2c5 100644 --- a/internal/cmd/git/root_directory_test.go +++ b/internal/cmd/git/root_directory_test.go @@ -23,7 +23,35 @@ func TestRootDirectorySetsOnlyTheRootDirectory(t *testing.T) { assert.Zero(t, api.accountRouteReads(), "nothing is resolved on GitHub") assert.Contains(t, out, "Root directory set to apps/web") assert.Contains(t, out, "Repository: octo/storefront") - assert.Contains(t, out, "Nothing was redeployed. The next push to main builds from this directory.") + assert.Contains(t, out, "Nothing was redeployed. The next deploy from the repository builds from this directory.") + 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 builds. +func TestRootDirectorySaysWhenAPushDeploysNothing(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.autoDeploy = false + + out, err := executeGitCommand(t, api.serve(), nil, "", "root-directory", "apps/web") + require.NoError(t, err) + assert.Contains(t, out, "Auto-deploy is off, so a push to main will not deploy anything.") +} + +// The directory is set even when the deploy settings cannot be read +// afterwards, which is said rather than read as "nothing deploys". +func TestRootDirectorySucceedsWhenTheDeploySettingsCannotBeRead(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + api.deploySettingsStatus = http.StatusInternalServerError + + out, err := executeGitCommand(t, api.serve(), nil, "", "root-directory", "apps/web") + require.NoError(t, err) + assert.Contains(t, out, "Root directory set to apps/web") + assert.Contains(t, out, "its deploy settings could not be read") } func TestRootDirectoryResetsToTheRepositoryRoot(t *testing.T) { diff --git a/internal/output/git.go b/internal/output/git.go index f8a312af..09b39ff6 100644 --- a/internal/output/git.go +++ b/internal/output/git.go @@ -68,9 +68,10 @@ func GitNotConnected(w io.Writer, project, connectCommand string) { fmt.Fprintf(w, "\n%s%s\n", theme.Dim("Connect one with ", on), theme.Command(connectCommand, on)) } -// GitRootDirectorySet renders a root directory that was just set. Nothing is -// redeployed by it, which is worth saying: the change only shows on the next -// push. +// GitRootDirectorySet renders a root directory that was just set, followed by +// what a push to the production branch deploys. Nothing is redeployed by it, +// which is worth saying: the change only shows on the next deploy, and only +// the deploy settings say whether a push makes one. func GitRootDirectorySet(w io.Writer, binding GitBinding) { connection := binding.Connection if root := strings.TrimSpace(connection.RootDirectory); root != "" { @@ -79,7 +80,8 @@ func GitRootDirectorySet(w io.Writer, binding GitBinding) { Success(w, "Root directory reset to the repository root") } gitBindingDetail(w, theme.On(w), binding) - Note(w, "Nothing was redeployed. The next push to %s builds from this directory.", connection.ProductionBranch) + Note(w, "Nothing was redeployed. The next deploy from the repository builds from this directory.") + gitDeploySettings(w, binding) } // GitDisconnected renders a removed binding. The repository is untouched, which From 97a40b550a97fcf44a23ecc33a0890ff764be186 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:58:05 -0400 Subject: [PATCH 3/4] fix(git): offer the root-directory command only for the connected repository --- docs/git.md | 3 ++- internal/cmd/git/connect.go | 9 +++++++-- internal/cmd/git/connect_test.go | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/git.md b/docs/git.md index d86adbd3..19078f26 100644 --- a/docs/git.md +++ b/docs/git.md @@ -164,7 +164,8 @@ out with `..`, is refused. Volcano stores it in the form a deploy reads it, so This works with a project access token, so a CI job or an agent can move a project within its repository. Connecting a different repository needs a person's account token. With a project access token, `volcano git connect ---root-directory` points to this command instead. +--root-directory` for the repository already connected points to this command +instead. Nothing is redeployed. The next deploy from the repository builds from the new directory. Like `connect`, the command then says what a push to the production diff --git a/internal/cmd/git/connect.go b/internal/cmd/git/connect.go index 7c55e8c9..66f62990 100644 --- a/internal/cmd/git/connect.go +++ b/internal/cmd/git/connect.go @@ -168,8 +168,13 @@ func runConnect(ctx context.Context, opts connectOptions) error { target, err := service.Resolve(ctx, repository) if err != nil { // Resolving reads the account's GitHub connections, which a project - // access token may not. The root directory alone has a route it may use. - if errors.Is(err, config.ErrAccountTokenRequired) && opts.rootDirectorySet && existing != nil { + // access token may not. The root directory alone has a route it may use, + // but only on the repository already connected, so it is offered only + // when that is the one asked for. Without a resolve the name is all there + // is to tell by; a repository renamed since it was connected misses the + // offer rather than risk pointing it at another one. + if errors.Is(err, config.ErrAccountTokenRequired) && opts.rootDirectorySet && + existing != nil && strings.EqualFold(existing.RepoFullName, repository.FullName()) { return fmt.Errorf("%w\n\nTo change only the root directory of the connected repository with this token, run:\n %s", err, rootDirectoryCommand(opts.deps, opts.rootDirectory)) } diff --git a/internal/cmd/git/connect_test.go b/internal/cmd/git/connect_test.go index 97b153e1..97fafda0 100644 --- a/internal/cmd/git/connect_test.go +++ b/internal/cmd/git/connect_test.go @@ -439,6 +439,29 @@ func TestConnectWithAProjectTokenPointsAtTheRootDirectoryCommand(t *testing.T) { assert.Nil(t, api.sentRootBody()) } +// The root-directory command only changes the repository already connected, so +// it is no answer for a project token asking to connect a different one. +func TestConnectWithAProjectTokenOffersNoRootDirectoryCommandForAnotherRepository(t *testing.T) { + setGitCommandTestHome(t) + setGitCommandTestToken(t, cliconfig.ProjectTokenPrefix+"token") + api := newGitAPI(t) + api.connected = "octo/storefront" + + for name, args := range map[string][]string{ + "checkout": {"connect", "--root-directory", "apps/api"}, + "explicit URL": {"connect", "https://github.com/octo/warehouse.git", "--root-directory", "apps/api"}, + } { + t.Run(name, func(t *testing.T) { + stdout := strings.ReplaceAll(originRemoteOutput, "storefront", "warehouse") + _, err := executeGitCommand(t, api.serve(), &gitRunner{stdout: stdout}, "", args...) + require.ErrorIs(t, err, cliconfig.ErrAccountTokenRequired) + assert.NotContains(t, err.Error(), "root-directory") + }) + } + assert.Nil(t, api.sentConnectBody()) + assert.Nil(t, api.sentRootBody()) +} + func TestConnectLeavesAnUnmentionedRootDirectoryAlone(t *testing.T) { setGitCommandTestHome(t) api := newGitAPI(t) From ded0c85ca1f4858332a5852d0ba70f5e02bfb094 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:33:21 -0400 Subject: [PATCH 4/4] fix(git): end flags before a suggested directory that starts with a hyphen --- internal/cmd/git/connect.go | 9 +++++++-- internal/cmd/git/connect_test.go | 2 ++ internal/cmd/git/root_directory_test.go | 13 +++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/cmd/git/connect.go b/internal/cmd/git/connect.go index 66f62990..59bef3e9 100644 --- a/internal/cmd/git/connect.go +++ b/internal/cmd/git/connect.go @@ -391,12 +391,17 @@ func sameRepositoryBinding(existing *apiclient.ProjectGitConnection, target *git } // rootDirectoryCommand is the root-directory command that sets rootDirectory, -// ready to paste. +// ready to paste. A directory starting with a hyphen follows "--", or it would +// be read as a flag: one named --reset would reset instead. func rootDirectoryCommand(deps cliruntime.Deps, rootDirectory string) string { if rootDirectory == "" { return cliruntime.CommandPath(deps, "git root-directory --reset") } - return cliruntime.CommandPath(deps, "git root-directory "+cliruntime.ShellQuote(rootDirectory)) + argument := cliruntime.ShellQuote(rootDirectory) + if strings.HasPrefix(rootDirectory, "-") { + argument = "-- " + argument + } + return cliruntime.CommandPath(deps, "git root-directory "+argument) } // rootDirectoryFor decides what the full-replace bind carries. An omitted flag diff --git a/internal/cmd/git/connect_test.go b/internal/cmd/git/connect_test.go index 97fafda0..cf976133 100644 --- a/internal/cmd/git/connect_test.go +++ b/internal/cmd/git/connect_test.go @@ -428,6 +428,8 @@ func TestConnectWithAProjectTokenPointsAtTheRootDirectoryCommand(t *testing.T) { for root, command := range map[string]string{ "apps/api": "volcano git root-directory apps/api", "apps/my x": "volcano git root-directory 'apps/my x'", + "--reset": "volcano git root-directory -- --reset", + "-web": "volcano git root-directory -- -web", "": "volcano git root-directory --reset", } { _, err := executeGitCommand(t, api.serve(), &gitRunner{stdout: originRemoteOutput}, diff --git a/internal/cmd/git/root_directory_test.go b/internal/cmd/git/root_directory_test.go index b5bae2c5..c0804c4f 100644 --- a/internal/cmd/git/root_directory_test.go +++ b/internal/cmd/git/root_directory_test.go @@ -80,6 +80,19 @@ func TestRootDirectoryWorksWithAProjectAccessToken(t *testing.T) { assert.Equal(t, map[string]any{"root_directory": "apps/web"}, api.sentRootBody()) } +// After "--" a directory named like a flag is a directory, which is how connect +// spells such a directory when it suggests this command. +func TestRootDirectoryTakesAHyphenatedDirectoryAfterDoubleDash(t *testing.T) { + setGitCommandTestHome(t) + api := newGitAPI(t) + api.connected = "octo/storefront" + + out, err := executeGitCommand(t, api.serve(), nil, "", "root-directory", "--", "--reset") + require.NoError(t, err) + assert.Equal(t, map[string]any{"root_directory": "--reset"}, api.sentRootBody()) + assert.Contains(t, out, "Root directory set to --reset") +} + func TestRootDirectoryRefusesWhatCannotMeanADirectory(t *testing.T) { for _, tc := range []struct { args []string