mirror of
https://github.com/abhinav/git-spice.git
synced 2026-08-31 07:47:47 +02:00
feat: Support fork remotes (#857)
autofix.ci / autofix (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Generate test matrix (push) Has been cancelled
CI / Test / ${{ matrix.name }} (push) Has been cancelled
CI / OK (push) Has been cancelled
GitHub Pages / build (push) Has been cancelled
GitHub Pages / deploy (push) Has been cancelled
autofix.ci / autofix (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Generate test matrix (push) Has been cancelled
CI / Test / ${{ matrix.name }} (push) Has been cancelled
CI / OK (push) Has been cancelled
GitHub Pages / build (push) Has been cancelled
GitHub Pages / deploy (push) Has been cancelled
Support fork-based contribution workflows by separating the repository remote into upstream and push roles. The upstream remote owns trunk and Change Requests, while the push remote receives submitted branch pushes. The state store now writes version 2 with a structured remote object and transparently migrates version 1 string remotes on open. Submit, sync, list, checkout, and forge integrations choose the remote role that matches the operation. Fork mode creates Change Requests only for branches based on trunk. Stack submission still pushes stacked branches to the push remote, but skips Change Request creation for non-trunk-based branches. The `repo init` help, generated CLI reference, website docs, changelog, and script coverage document the workflow. Resolves #413
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
kind: Added
|
||||
body: 'fork mode: Support separate upstream and push remotes for fork-based workflows. Change Requests are opened against the upstream remote for trunk-based branches, submitted branches are pushed to the push remote, and trunk synchronization uses the upstream remote.'
|
||||
time: 2026-05-02T00:00:01-07:00
|
||||
@@ -0,0 +1,3 @@
|
||||
kind: Changed
|
||||
body: 'storage: Change repository storage to support fork mode. Repositories using fork mode cannot be opened with older git-spice versions.'
|
||||
time: 2026-05-02T00:00:03-07:00
|
||||
@@ -111,9 +111,8 @@ func guessCurrentForge(ctx context.Context, forges *forge.Registry, log *silog.L
|
||||
// and a remote is configured, use the forge for that remote.
|
||||
var remote string
|
||||
if store, err := state.OpenStore(ctx, newRepoStorage(repo, log), log); err == nil {
|
||||
remote, err = store.Remote()
|
||||
if err != nil {
|
||||
remote = ""
|
||||
if r, err := store.Remote(); err == nil {
|
||||
remote = r.Upstream
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,12 +116,17 @@ A prompt will ask for one if not provided with --trunk.
|
||||
|
||||
Most branch stacking operations are local
|
||||
and do not require a network connection.
|
||||
For operations that push or pull commits, a remote is required.
|
||||
A prompt will ask for one during initialization
|
||||
For operations that push or pull commits, remotes are required.
|
||||
A prompt will ask for them during initialization
|
||||
if not provided with --remote.
|
||||
|
||||
The upstream remote hosts trunk and receives change requests.
|
||||
The push remote receives submitted branch pushes.
|
||||
If only --remote is provided,
|
||||
it is used as both the upstream and push remote.
|
||||
|
||||
Re-run the command on an already initialized repository
|
||||
to change the trunk or remote.
|
||||
to change the trunk or remotes.
|
||||
If the trunk branch is changed on re-initialization,
|
||||
existing branches stacked on the old trunk
|
||||
will be updated to point to the new trunk.
|
||||
@@ -132,7 +137,8 @@ and untrack all branches.
|
||||
**Flags**
|
||||
|
||||
* `--trunk=BRANCH`: Name of the trunk branch
|
||||
* `--remote=NAME`: Name of the remote to push changes to
|
||||
* `--remote=NAME`: Name of the remote to push submitted branches to
|
||||
* `--upstream=NAME`: Name of the remote to open change requests against
|
||||
* `--reset`: Forget all information about the repository
|
||||
|
||||
### git-spice repo sync {#gs-repo-sync}
|
||||
|
||||
@@ -43,6 +43,35 @@ keeping them up-to-date and in sync with each other.
|
||||
git-spice stores authentication tokens in a system-specific secure storage.
|
||||
See [Authentication > Safety](../setup/auth.md#safety) for details.
|
||||
|
||||
## Can I use git-spice with a fork of a repository?
|
||||
|
||||
<!-- gs:version unreleased -->
|
||||
|
||||
Yes.
|
||||
|
||||
Configure the upstream repository as the upstream remote
|
||||
and your fork as the push remote:
|
||||
|
||||
```freeze language="terminal"
|
||||
{green}${reset} git clone https://github.com/your-username/project.git
|
||||
{green}${reset} cd project
|
||||
{gray}# This gives us a repository with origin set to your fork.{reset}
|
||||
|
||||
{green}${reset} git remote add {red}upstream{reset} https://github.com/example/project.git
|
||||
{green}${reset} git fetch {red}upstream{reset}
|
||||
{green}${reset} gs repo init {green}--upstream {red}upstream{reset} {green}--remote {red}origin{reset}
|
||||
```
|
||||
|
||||
After that,
|
||||
git-spice will push your branches to your fork (`origin`),
|
||||
and open Change Requests against the upstream repository (`upstream`).
|
||||
|
||||
Fork mode has some limitations, chief among them being
|
||||
that Change Requests are only created for branches based directly on trunk.
|
||||
Branches stacked on top of other local branches are still pushed to your fork,
|
||||
but Change Requests cannot be created for them
|
||||
until their base branch is merged into trunk, and they are rebased on top.
|
||||
|
||||
## Why doesn't git-spice create one CR per commit?
|
||||
|
||||
With tooling like this, there are two options:
|
||||
|
||||
@@ -70,6 +70,8 @@ line from 1/2 way between A.n and B.s \
|
||||
text "Sibling" with s at F.n
|
||||
```
|
||||
|
||||
## Working with local branches
|
||||
|
||||
**Branch**
|
||||
: A regular Git branch.
|
||||
Branches can have a *base*: the branch they were created from.
|
||||
@@ -82,14 +84,6 @@ text "Sibling" with s at F.n
|
||||
This is "main" or "master" in most repositories.
|
||||
Trunk is the only branch that does not have a base branch.
|
||||
|
||||
**Change Request**
|
||||
: Change Request refers to a single merge-able unit of work
|
||||
submitted to GitHub, GitLab, or Bitbucket.
|
||||
Each Change Request corresponds to a branch.
|
||||
On GitHub and Bitbucket, these are called Pull Requests,
|
||||
and on GitLab, they are called Merge Requests.
|
||||
Since git-spice supports all three platforms,
|
||||
the term Change Request is used to refer to all of them.
|
||||
|
||||
**Stack**
|
||||
: A stack is a collection of branches stacked on top of each other
|
||||
@@ -119,3 +113,54 @@ text "Sibling" with s at F.n
|
||||
on top of its base branch, which it may have diverged from.
|
||||
This is done to keep the branch up-to-date with its base branch,
|
||||
and maintain a linear history.
|
||||
|
||||
## Working with remote repositories
|
||||
|
||||
**Change Request**
|
||||
: Change Request refers to a single merge-able unit of work
|
||||
submitted to GitHub, GitLab, or Bitbucket.
|
||||
Each Change Request corresponds to a branch.
|
||||
On GitHub and Bitbucket, these are called Pull Requests,
|
||||
and on GitLab, they are called Merge Requests.
|
||||
Since git-spice supports all three platforms,
|
||||
the term Change Request is used to refer to all of them.
|
||||
|
||||
**Upstream remote**
|
||||
: The Git remote that hosts the trunk branch
|
||||
and receives Change Requests.
|
||||
Operations that pull trunk (e.g. $$gs repo sync$$)
|
||||
or manipulate Change Requests (e.g. $$gs branch submit$$)
|
||||
use the upstream remote.
|
||||
|
||||
In a same-repository workflow,
|
||||
the upstream remote and push remote are the same.
|
||||
In a fork workflow,
|
||||
the upstream remote points to the target repository,
|
||||
and the push remote points to your fork.
|
||||
|
||||
**Push remote**
|
||||
: The Git remote that receives submitted branch pushes.
|
||||
Operations that publish branch commits
|
||||
or inspect remote branch state use the push remote.
|
||||
|
||||
In a same-repository workflow,
|
||||
the upstream remote and push remote are the same.
|
||||
In a fork workflow,
|
||||
the upstream remote points to the target repository,
|
||||
and the push remote points to your fork.
|
||||
|
||||
**Fork mode**
|
||||
: Fork mode is enabled when the push remote and upstream remote differ.
|
||||
This may be used when you do not have write access to the target repository,
|
||||
and instead maintain a fork of the repository that you push branches to.
|
||||
|
||||
In fork mode, git-spice pushes branches to the push remote,
|
||||
and opens Change Requests against the upstream remote.
|
||||
|
||||
Change Requests are opened from the push remote to the upstream remote,
|
||||
only for branches that are based directly on trunk.
|
||||
Branches stacked on top of another local branch are still pushed to your fork,
|
||||
but git-spice does not create Change Requests for them.
|
||||
|
||||
See [Fork Workflows](cr.md#fork-workflows)
|
||||
for setup and workflow examples.
|
||||
|
||||
+53
-4
@@ -58,10 +58,59 @@ For example:
|
||||
|
||||
!!! important
|
||||
|
||||
Be aware that for stacks with multiple branches,
|
||||
you must have write access to the repository
|
||||
so that you can push branches to it.
|
||||
See [Limitations](limits.md) for more information.
|
||||
Creating a fully stacked series of Change Requests requires
|
||||
write access to the upstream repository.
|
||||
git-spice must be able to push each branch in the stack
|
||||
to the repository that receives the Change Requests.
|
||||
|
||||
Fork workflows use a different submission model.
|
||||
See [Fork workflows](#fork-workflows)
|
||||
and [Limitations](limits.md) for details.
|
||||
|
||||
### Fork workflows
|
||||
|
||||
<!-- gs:version unreleased -->
|
||||
|
||||
git-spice supports fork-based contribution workflows
|
||||
by storing separate upstream and push remotes.
|
||||
The upstream remote hosts trunk and receives Change Requests.
|
||||
The push remote receives branch pushes,
|
||||
and is usually your fork.
|
||||
|
||||
For example,
|
||||
if `origin` points to your fork
|
||||
and `upstream` points to the project repository,
|
||||
initialize the repository like this:
|
||||
|
||||
```freeze language="terminal"
|
||||
{green}${reset} gs repo init --upstream upstream --remote origin
|
||||
```
|
||||
|
||||
After initialization,
|
||||
submission commands such as $$gs branch submit$$
|
||||
push branches to `origin`
|
||||
and open Change Requests against `upstream`.
|
||||
Synchronization commands such as $$gs repo sync$$ pull trunk from `upstream`.
|
||||
|
||||
```freeze language="terminal"
|
||||
{yellow}[feature]{reset} {green}${reset} gs branch submit --fill
|
||||
{green}INF{reset} Created #123: https://github.com/example/project/pull/123
|
||||
```
|
||||
|
||||
Submitting stacks with $$gs stack submit$$ or friends
|
||||
creates Change Requests only for branches that are based directly on trunk.
|
||||
All other branches are still pushed to the push remote (your fork),
|
||||
but git-spice does not create Change Requests for them
|
||||
until their base branch is merged
|
||||
and they are restacked on top of trunk.
|
||||
|
||||
```freeze language="terminal"
|
||||
{green}${reset} git branch --show-current
|
||||
{yellow}feature-a{reset}
|
||||
{green}${reset} gs stack submit --fill
|
||||
{green}INF{reset} Created #123: https://github.com/example/project/pull/123
|
||||
{green}INF{reset} feature-b: Pushing to origin, skipping CR: base is feature-a
|
||||
```
|
||||
|
||||
### Navigation comments
|
||||
|
||||
|
||||
+20
-1
@@ -11,7 +11,7 @@ runs into limitations of what is possible on those platforms,
|
||||
and how they handle Git commits.
|
||||
Some limitations imposed on git-spice are listed below.
|
||||
|
||||
## Write access required
|
||||
## Write access required for stacked CRs
|
||||
|
||||
When a branch `F` is stacked on another branch `B`,
|
||||
and you want to submit Change Requests for both,
|
||||
@@ -22,6 +22,25 @@ Therefore, to use git-spice to stack PRs,
|
||||
you need write access to the repository:
|
||||
specifically the ability to push new branches.
|
||||
|
||||
## Fork mode submits only trunk-based branches
|
||||
|
||||
<!-- gs:version unreleased -->
|
||||
|
||||
When the upstream and push remotes differ,
|
||||
git-spice uses fork mode.
|
||||
In this mode,
|
||||
branch pushes go to the push remote,
|
||||
and Change Requests are opened against the upstream remote.
|
||||
|
||||
Fork mode creates Change Requests only for branches
|
||||
that are based directly on trunk.
|
||||
Branches stacked on top of another local branch are still pushed
|
||||
to the push remote,
|
||||
but stack submission commands skip Change Request creation for them.
|
||||
|
||||
To submit a fully stacked series of Change Requests,
|
||||
push access to the upstream repository is still required.
|
||||
|
||||
## Squash-merges restack the upstack
|
||||
|
||||
On GitHub, when a Pull Request is squash-merged into the trunk branch,
|
||||
|
||||
@@ -62,6 +62,15 @@ git-spice refers to these as *Change Requests* (CRs).
|
||||
If you don't have the GitLab CLI installed,
|
||||
go to <https://gitlab.com/projects/new> and follow the instructions there.
|
||||
|
||||
!!! note "Forked repositories"
|
||||
|
||||
git-spice can also be used with forked repositories,
|
||||
with some limitations.
|
||||
See [Fork workflows](../guide/cr.md#fork-workflows) for details.
|
||||
|
||||
For this tutorial,
|
||||
we recommend using a repository you can push branches to directly.
|
||||
|
||||
## Create a Change Request
|
||||
|
||||
1. Check out `feat1`.
|
||||
|
||||
@@ -32,7 +32,7 @@ type Options struct {
|
||||
type Store interface {
|
||||
// Trunk returns the name of the trunk branch.
|
||||
Trunk() string
|
||||
Remote() (string, error)
|
||||
Remote() (state.Remote, error)
|
||||
}
|
||||
|
||||
// GitWorktree allows changing which branch or commit
|
||||
@@ -112,22 +112,22 @@ func (h *Handler) CheckoutBranch(ctx context.Context, req *Request) error {
|
||||
// Try to recover by checking if the branch exists in the remote.
|
||||
var recovered bool
|
||||
if remote, err := h.Store.Remote(); err == nil {
|
||||
upstreamBranch := fmt.Sprintf("%s/%s", remote, branch)
|
||||
if upstreamHead, err := h.Repository.PeelToCommit(ctx, upstreamBranch); err == nil {
|
||||
h.Log.Infof("%v: found remote branch %v, checking out", branch, upstreamBranch)
|
||||
remoteBranch := fmt.Sprintf("%s/%s", remote.Push, branch)
|
||||
if remoteHead, err := h.Repository.PeelToCommit(ctx, remoteBranch); err == nil {
|
||||
h.Log.Infof("%v: found remote branch %v, checking out", branch, remoteBranch)
|
||||
|
||||
createReq := git.CreateBranchRequest{
|
||||
Name: branch,
|
||||
Head: string(upstreamHead),
|
||||
Head: string(remoteHead),
|
||||
}
|
||||
if err := h.Repository.CreateBranch(ctx, createReq); err != nil {
|
||||
return fmt.Errorf("create branch from remote %q: %w", upstreamBranch, err)
|
||||
return fmt.Errorf("create branch from remote %q: %w", remoteBranch, err)
|
||||
}
|
||||
|
||||
if err := h.Repository.SetBranchUpstream(ctx, branch, upstreamBranch); err != nil {
|
||||
if err := h.Repository.SetBranchUpstream(ctx, branch, remoteBranch); err != nil {
|
||||
// Non-fatal error; just log it.
|
||||
log.Error("Error setting upstream for branch",
|
||||
"name", branch, "upstream", upstreamBranch, "error", err)
|
||||
"name", branch, "upstream", remoteBranch, "error", err)
|
||||
}
|
||||
|
||||
recovered = true
|
||||
|
||||
@@ -327,7 +327,7 @@ func TestHandler_CheckoutBranch_NonTrunk(t *testing.T) {
|
||||
mockStore.
|
||||
EXPECT().
|
||||
Remote().
|
||||
Return("", git.ErrNotExist)
|
||||
Return(state.Remote{}, git.ErrNotExist)
|
||||
|
||||
err := handler.CheckoutBranch(t.Context(), &Request{
|
||||
Branch: "feature",
|
||||
@@ -360,7 +360,7 @@ func TestHandler_CheckoutBranch_NonTrunk(t *testing.T) {
|
||||
mockStore.
|
||||
EXPECT().
|
||||
Remote().
|
||||
Return("origin", nil)
|
||||
Return(state.Remote{Upstream: "origin", Push: "origin"}, nil)
|
||||
mockRepo.
|
||||
EXPECT().
|
||||
PeelToCommit(gomock.Any(), "origin/feature").
|
||||
@@ -413,7 +413,7 @@ func TestHandler_CheckoutBranch_NonTrunk(t *testing.T) {
|
||||
mockStore.
|
||||
EXPECT().
|
||||
Remote().
|
||||
Return("origin", nil)
|
||||
Return(state.Remote{Upstream: "origin", Push: "origin"}, nil)
|
||||
mockRepo.
|
||||
EXPECT().
|
||||
PeelToCommit(gomock.Any(), "origin/feature").
|
||||
@@ -465,7 +465,7 @@ func TestHandler_CheckoutBranch_NonTrunk(t *testing.T) {
|
||||
mockStore.
|
||||
EXPECT().
|
||||
Remote().
|
||||
Return("origin", nil)
|
||||
Return(state.Remote{Upstream: "origin", Push: "origin"}, nil)
|
||||
mockRepo.
|
||||
EXPECT().
|
||||
PeelToCommit(gomock.Any(), "origin/feature").
|
||||
@@ -508,7 +508,7 @@ func TestHandler_CheckoutBranch_NonTrunk(t *testing.T) {
|
||||
mockStore.
|
||||
EXPECT().
|
||||
Remote().
|
||||
Return("origin", nil)
|
||||
Return(state.Remote{Upstream: "origin", Push: "origin"}, nil)
|
||||
mockRepo.
|
||||
EXPECT().
|
||||
PeelToCommit(gomock.Any(), "origin/feature").
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
git "go.abhg.dev/gs/internal/git"
|
||||
track "go.abhg.dev/gs/internal/handler/track"
|
||||
state "go.abhg.dev/gs/internal/spice/state"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
@@ -406,10 +407,10 @@ func (m *MockStore) EXPECT() *MockStoreMockRecorder {
|
||||
}
|
||||
|
||||
// Remote mocks base method.
|
||||
func (m *MockStore) Remote() (string, error) {
|
||||
func (m *MockStore) Remote() (state.Remote, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Remote")
|
||||
ret0, _ := ret[0].(string)
|
||||
ret0, _ := ret[0].(state.Remote)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
@@ -427,19 +428,19 @@ type MockStoreRemoteCall struct {
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockStoreRemoteCall) Return(arg0 string, arg1 error) *MockStoreRemoteCall {
|
||||
func (c *MockStoreRemoteCall) Return(arg0 state.Remote, arg1 error) *MockStoreRemoteCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockStoreRemoteCall) Do(f func() (string, error)) *MockStoreRemoteCall {
|
||||
func (c *MockStoreRemoteCall) Do(f func() (state.Remote, error)) *MockStoreRemoteCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockStoreRemoteCall) DoAndReturn(f func() (string, error)) *MockStoreRemoteCall {
|
||||
func (c *MockStoreRemoteCall) DoAndReturn(f func() (state.Remote, error)) *MockStoreRemoteCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ var _ GitRepository = (*git.Repository)(nil)
|
||||
|
||||
// Store provides access to git-spice's state store.
|
||||
type Store interface {
|
||||
Remote() (string, error)
|
||||
Remote() (state.Remote, error)
|
||||
Trunk() string
|
||||
}
|
||||
|
||||
@@ -173,10 +173,10 @@ func (h *Handler) ListBranches(ctx context.Context, req *BranchesRequest) (*Bran
|
||||
return nil, fmt.Errorf("load branch graph: %w", err)
|
||||
}
|
||||
|
||||
getRemote := sync.OnceValue(func() string {
|
||||
getRemote := sync.OnceValue(func() state.Remote {
|
||||
remote, err := h.Store.Remote()
|
||||
if err != nil {
|
||||
return ""
|
||||
return state.Remote{}
|
||||
}
|
||||
return remote
|
||||
})
|
||||
@@ -188,8 +188,11 @@ func (h *Handler) ListBranches(ctx context.Context, req *BranchesRequest) (*Bran
|
||||
if req.Include&needsRemoteID != 0 {
|
||||
err := func() error {
|
||||
remote := getRemote()
|
||||
if remote == (state.Remote{}) {
|
||||
return state.ErrNotExist
|
||||
}
|
||||
|
||||
remoteURL, err := h.Repository.RemoteURL(ctx, remote)
|
||||
remoteURL, err := h.Repository.RemoteURL(ctx, remote.Upstream)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get remote URL: %w", err)
|
||||
}
|
||||
@@ -283,13 +286,16 @@ func (h *Handler) ListBranches(ctx context.Context, req *BranchesRequest) (*Bran
|
||||
}
|
||||
|
||||
if req.Include&IncludePushStatus != 0 && branch.UpstreamBranch != "" {
|
||||
upstream := getRemote() + "/" + branch.UpstreamBranch
|
||||
ahead, behind, err := h.Repository.CommitAheadBehind(ctx, upstream, string(branch.Head))
|
||||
if err == nil {
|
||||
item.PushStatus = &PushStatus{
|
||||
Ahead: ahead,
|
||||
Behind: behind,
|
||||
NeedsPush: ahead > 0 || behind > 0,
|
||||
remote := getRemote()
|
||||
if remote != (state.Remote{}) {
|
||||
upstream := remote.Push + "/" + branch.UpstreamBranch
|
||||
ahead, behind, err := h.Repository.CommitAheadBehind(ctx, upstream, string(branch.Head))
|
||||
if err == nil {
|
||||
item.PushStatus = &PushStatus{
|
||||
Ahead: ahead,
|
||||
Behind: behind,
|
||||
NeedsPush: ahead > 0 || behind > 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ var _ GitRepository = (*git.Repository)(nil)
|
||||
// Store is the git-spice data store.
|
||||
type Store interface {
|
||||
Trunk() string
|
||||
Remote() (string, error)
|
||||
Remote() (state.Remote, error)
|
||||
BeginBranchTx() *state.BranchTx
|
||||
}
|
||||
|
||||
@@ -375,7 +375,7 @@ func (h *Handler) prepareChangeMetadataTransfer(
|
||||
"from", fromBranch, "to", toBranch)
|
||||
|
||||
return func() {
|
||||
if err := h.Repository.SetBranchUpstream(ctx, toBranch, remote+"/"+toUpstreamBranch); err != nil {
|
||||
if err := h.Repository.SetBranchUpstream(ctx, toBranch, remote.Push+"/"+toUpstreamBranch); err != nil {
|
||||
h.Log.Warnf("%v: Failed to set upstream branch %v: %v", toBranch, toUpstreamBranch, err)
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@ func (h *Handler) prepareChangeMetadataTransfer(
|
||||
|
||||
h.Log.Infof("%v: Upstream branch '%v' transferred to '%v'", fromBranch, toUpstreamBranch, toBranch)
|
||||
if toUpstreamBranch == fromBranch {
|
||||
pushCmd := fmt.Sprintf("git push -u %v %v:<new name>", remote, fromBranch)
|
||||
pushCmd := fmt.Sprintf("git push -u %v %v:<new name>", remote.Push, fromBranch)
|
||||
highlight := h.HighlightStyle.Resolve(h.View.Theme())
|
||||
|
||||
h.Log.Warnf("%v: If you push this branch with 'git push' instead of '%s branch submit',", fromBranch, cli.Name())
|
||||
|
||||
@@ -88,30 +88,96 @@ type Handler struct {
|
||||
|
||||
// TODO: these should not be a func reference
|
||||
// this whole memoize thing is a bit of a hack
|
||||
FindRemote func(ctx context.Context) (string, error) // required
|
||||
OpenRemoteRepository func(ctx context.Context, remote string) (forge.Repository, error) // required
|
||||
remote memoizedValue[string]
|
||||
remoteRepository memoizedValue[forge.Repository]
|
||||
|
||||
// FindRemote returns the configured upstream and push remotes.
|
||||
FindRemote func(ctx context.Context) (state.Remote, error) // required
|
||||
|
||||
// ResolveRepository resolves a remote name to its forge repository.
|
||||
ResolveRepository func(ctx context.Context, remote string) (forge.Forge, forge.RepositoryID, error) // required
|
||||
|
||||
// OpenRepository opens a resolved forge repository.
|
||||
OpenRepository func(ctx context.Context, f forge.Forge, repo forge.RepositoryID) (forge.Repository, error) // required
|
||||
|
||||
_remote memoizedValue[state.Remote]
|
||||
_upstream memoizedValue[resolvedRepository]
|
||||
_upstreamRepo memoizedValue[forge.Repository]
|
||||
_pushRepoID memoizedValue[forge.RepositoryID]
|
||||
}
|
||||
|
||||
// Remote returns the remote name for the current repository,
|
||||
// remote returns the remotes for the current repository,
|
||||
// memoizing the result.
|
||||
func (h *Handler) Remote(ctx context.Context) (string, error) {
|
||||
return h.remote.Get(func() (string, error) {
|
||||
func (h *Handler) remote(ctx context.Context) (state.Remote, error) {
|
||||
return h._remote.Get(func() (state.Remote, error) {
|
||||
return h.FindRemote(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// RemoteRepository returns the remote repository for the current repository,
|
||||
// resolvedRepository is a remote resolved to forge coordinates.
|
||||
type resolvedRepository struct {
|
||||
forge forge.Forge
|
||||
id forge.RepositoryID
|
||||
}
|
||||
|
||||
func (h *Handler) upstream(ctx context.Context) (resolvedRepository, error) {
|
||||
return h._upstream.Get(func() (resolvedRepository, error) {
|
||||
remote, err := h.remote(ctx)
|
||||
if err != nil {
|
||||
return resolvedRepository{}, fmt.Errorf("get remote: %w", err)
|
||||
}
|
||||
|
||||
f, repoID, err := h.ResolveRepository(ctx, remote.Upstream)
|
||||
if err != nil {
|
||||
return resolvedRepository{}, fmt.Errorf("resolve remote repository: %w", err)
|
||||
}
|
||||
|
||||
return resolvedRepository{
|
||||
forge: f,
|
||||
id: repoID,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// upstreamRepository returns the remote repository for the current repository,
|
||||
// memoizing the result.
|
||||
func (h *Handler) RemoteRepository(ctx context.Context) (forge.Repository, error) {
|
||||
return h.remoteRepository.Get(func() (forge.Repository, error) {
|
||||
remote, err := h.Remote(ctx)
|
||||
func (h *Handler) upstreamRepository(ctx context.Context) (forge.Repository, error) {
|
||||
return h._upstreamRepo.Get(func() (forge.Repository, error) {
|
||||
upstream, err := h.upstream(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return h.OpenRepository(ctx, upstream.forge, upstream.id)
|
||||
})
|
||||
}
|
||||
|
||||
// pushRepositoryID returns the repository ID for the push remote,
|
||||
// memoizing the result.
|
||||
func (h *Handler) pushRepositoryID(ctx context.Context) (forge.RepositoryID, error) {
|
||||
return h._pushRepoID.Get(func() (forge.RepositoryID, error) {
|
||||
remote, err := h.remote(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get remote: %w", err)
|
||||
}
|
||||
if remote.Push == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return h.OpenRemoteRepository(ctx, remote)
|
||||
upstream, err := h.upstream(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, repoID, err := h.ResolveRepository(ctx, remote.Push)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.ID() != upstream.forge.ID() {
|
||||
return nil, fmt.Errorf(
|
||||
"push remote %q uses different forge %q than upstream remote %q: %q",
|
||||
remote.Push, f.ID(), remote.Upstream, upstream.forge.ID(),
|
||||
)
|
||||
}
|
||||
return repoID, nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -364,7 +430,6 @@ func (h *Handler) SubmitBatch(ctx context.Context, req *BatchRequest) error {
|
||||
for _, branch := range req.Branches {
|
||||
// Shallow copy the options because submitBranch may modify them.
|
||||
opts := *opts
|
||||
|
||||
status, err := h.submitBranch(
|
||||
ctx,
|
||||
branch,
|
||||
@@ -390,7 +455,7 @@ func (h *Handler) SubmitBatch(ctx context.Context, req *BatchRequest) error {
|
||||
opts.NavCommentDownstack,
|
||||
opts.NavCommentMarker,
|
||||
branchesToComment,
|
||||
h.RemoteRepository,
|
||||
h.upstreamRepository,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -437,7 +502,7 @@ func (h *Handler) Submit(ctx context.Context, req *Request) error {
|
||||
opts.NavCommentDownstack,
|
||||
opts.NavCommentMarker,
|
||||
[]string{req.Branch},
|
||||
h.RemoteRepository,
|
||||
h.upstreamRepository,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -488,15 +553,20 @@ func (h *Handler) submitBranch(
|
||||
return status, fmt.Errorf("peel to commit: %w", err)
|
||||
}
|
||||
|
||||
remote, err := h.Remote(ctx)
|
||||
remote, err := h.remote(ctx)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("get remote: %w", err)
|
||||
}
|
||||
if remote.ForkMode() && branch.Base != h.Store.Trunk() {
|
||||
h.Log.Infof("%v: Pushing to %v, skipping CR: base is %v",
|
||||
branchToSubmit, remote.Push, branch.Base)
|
||||
opts.Publish = false
|
||||
}
|
||||
|
||||
// Prefer the upstream branch name stored in the data store if available.
|
||||
// This is how we account for branches that have been renamed after submitting.
|
||||
storedUpstream := branch.UpstreamBranch
|
||||
upstreamBranch, err := h.resolveUpstreamBranch(ctx, remote, branchToSubmit, storedUpstream)
|
||||
upstreamBranch, err := h.resolveUpstreamBranch(ctx, remote.Push, branchToSubmit, storedUpstream)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("resolve upstream branch: %w", err)
|
||||
}
|
||||
@@ -520,7 +590,7 @@ func (h *Handler) submitBranch(
|
||||
// If the branch doesn't have a CR associated with it,
|
||||
// we'll probably need to create one,
|
||||
// but verify that there isn't already one open.
|
||||
remoteRepo, err := h.RemoteRepository(ctx)
|
||||
remoteRepo, err := h.upstreamRepository(ctx)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("discover CR for %s: %w", branchToSubmit, err)
|
||||
}
|
||||
@@ -529,9 +599,15 @@ func (h *Handler) submitBranch(
|
||||
// or the branch name itself if we don't have an upstream branch.
|
||||
// In case of the latter, we'll need to verify that the HEAD matches.
|
||||
crBranch := cmp.Or(upstreamBranch, branchToSubmit)
|
||||
pushRepo, err := h.pushRepositoryID(ctx)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("get push repository: %w", err)
|
||||
}
|
||||
|
||||
changes, err := remoteRepo.FindChangesByBranch(ctx, crBranch, forge.FindChangesOptions{
|
||||
State: forge.ChangeOpen,
|
||||
Limit: 3,
|
||||
State: forge.ChangeOpen,
|
||||
PushRepository: pushRepo,
|
||||
Limit: 3,
|
||||
})
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("list changes: %w", err)
|
||||
@@ -614,7 +690,7 @@ func (h *Handler) submitBranch(
|
||||
// TODO: Ask the user to pick one and associate it with the branch.
|
||||
}
|
||||
} else if branch.Change != nil {
|
||||
remoteRepo, err := h.RemoteRepository(ctx)
|
||||
remoteRepo, err := h.upstreamRepository(ctx)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("look up CR %v: %w", branch.Change.ChangeID(), err)
|
||||
}
|
||||
@@ -642,7 +718,7 @@ func (h *Handler) submitBranch(
|
||||
// may no longer reflect the user's intent for a replacement CR.
|
||||
// Re-read the branch's current upstream configuration and prefer it
|
||||
// over the stored upstream branch name for the new submission.
|
||||
upstreamBranch, err = h.resolveUpstreamBranch(ctx, remote, branchToSubmit, "")
|
||||
upstreamBranch, err = h.resolveUpstreamBranch(ctx, remote.Push, branchToSubmit, "")
|
||||
if err != nil {
|
||||
upstreamBranch = cmp.Or(storedUpstream, branchToSubmit)
|
||||
} else if upstreamBranch == "" {
|
||||
@@ -671,13 +747,13 @@ func (h *Handler) submitBranch(
|
||||
// At this point, existingChange is nil only if we need to create a new CR.
|
||||
if existingChange == nil {
|
||||
if upstreamBranch == "" {
|
||||
unique, err := svc.UnusedBranchName(ctx, remote, branchToSubmit)
|
||||
unique, err := svc.UnusedBranchName(ctx, remote.Push, branchToSubmit)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("find unique branch name: %w", err)
|
||||
}
|
||||
|
||||
if unique != branchToSubmit {
|
||||
log.Infof("%v: Branch name already in use in remote '%v'", branchToSubmit, remote)
|
||||
log.Infof("%v: Branch name already in use in remote '%v'", branchToSubmit, remote.Push)
|
||||
log.Infof("%v: Using upstream name '%v' instead", branchToSubmit, unique)
|
||||
}
|
||||
upstreamBranch = unique
|
||||
@@ -731,9 +807,9 @@ func (h *Handler) submitBranch(
|
||||
// Otherwise, we will push to origin/feature,
|
||||
// but won't have a local refs/remotes/origin/feature
|
||||
// to track it after a 'git fetch'.
|
||||
if refspecs, err := h.Repository.RemoteFetchRefspecs(ctx, remote); err != nil {
|
||||
if refspecs, err := h.Repository.RemoteFetchRefspecs(ctx, remote.Push); err != nil {
|
||||
log.Warn("Unable to verify remote's fetch refspecs",
|
||||
"remote", remote,
|
||||
"remote", remote.Push,
|
||||
"error", err)
|
||||
} else {
|
||||
wantMatch := "refs/heads/" + upstreamBranch
|
||||
@@ -746,7 +822,7 @@ func (h *Handler) submitBranch(
|
||||
}
|
||||
|
||||
if !hasMatch && !opts.Force {
|
||||
log.Errorf("Remote '%v' has refspecs:", remote)
|
||||
log.Errorf("Remote '%v' has refspecs:", remote.Push)
|
||||
for _, refspec := range refspecs {
|
||||
log.Errorf(" - %v", refspec)
|
||||
}
|
||||
@@ -756,11 +832,11 @@ func (h *Handler) submitBranch(
|
||||
log.Error("To fix this, you can do one of the following:")
|
||||
log.Errorf("1. Manually add a fetch refspec for just this branch:")
|
||||
log.Errorf(" git config --add remote.%v.fetch +refs/heads/%v:refs/remotes/%v/%v",
|
||||
remote, upstreamBranch, remote, upstreamBranch)
|
||||
remote.Push, upstreamBranch, remote.Push, upstreamBranch)
|
||||
log.Errorf("2. Prefix all your branches with your username (e.g. '%v/%v'),", user, upstreamBranch)
|
||||
log.Errorf(" and add a fetch refspec to fetch all branches under that prefix:")
|
||||
log.Errorf(" git config --add remote.%v.fetch '+refs/heads/%v/*:refs/remotes/%v/%v/*'",
|
||||
remote, user, remote, user)
|
||||
remote.Push, user, remote.Push, user)
|
||||
log.Errorf(" You can configure git-spice to automatically add this prefix for future branches with:")
|
||||
log.Errorf(" git config --global spice.branchCreate.prefix %v/", user)
|
||||
log.Errorf("3. Use the --force flag to push anyway (not recommended).")
|
||||
@@ -772,11 +848,16 @@ func (h *Handler) submitBranch(
|
||||
if opts.Publish {
|
||||
needsNavComment()
|
||||
|
||||
remoteRepo, err := h.RemoteRepository(ctx)
|
||||
remoteRepo, err := h.upstreamRepository(ctx)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("prepare publish: %w", err)
|
||||
}
|
||||
|
||||
pushRepo, err := h.pushRepositoryID(ctx)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("get push repository: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Refactor:
|
||||
// NoPublish and DryRun are checked repeatedly.
|
||||
// Extract the logic that needs them into no-ops
|
||||
@@ -784,9 +865,10 @@ func (h *Handler) submitBranch(
|
||||
prepared, err = h.prepareBranch(
|
||||
ctx,
|
||||
branchToSubmit,
|
||||
remote, // TODO: need this?
|
||||
remote.Upstream,
|
||||
remoteRepo,
|
||||
upstreamBranch, branch.Base, upstreamBase,
|
||||
pushRepo,
|
||||
opts,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -795,7 +877,7 @@ func (h *Handler) submitBranch(
|
||||
}
|
||||
|
||||
pushOpts := git.PushOptions{
|
||||
Remote: remote,
|
||||
Remote: remote.Push,
|
||||
Refspec: git.Refspec(
|
||||
commitHash.String() + ":refs/heads/" + upstreamBranch,
|
||||
),
|
||||
@@ -808,7 +890,7 @@ func (h *Handler) submitBranch(
|
||||
// Use a --force-with-lease to avoid
|
||||
// overwriting someone else's changes.
|
||||
if !opts.Force {
|
||||
existingHash, err := h.Repository.PeelToCommit(ctx, remote+"/"+upstreamBranch)
|
||||
existingHash, err := h.Repository.PeelToCommit(ctx, remote.Push+"/"+upstreamBranch)
|
||||
if err == nil {
|
||||
pushOpts.ForceWithLease = upstreamBranch + ":" + existingHash.String()
|
||||
}
|
||||
@@ -841,9 +923,9 @@ func (h *Handler) submitBranch(
|
||||
}
|
||||
}()
|
||||
|
||||
upstream := remote + "/" + upstreamBranch
|
||||
upstream := remote.Push + "/" + upstreamBranch
|
||||
if err := h.Repository.SetBranchUpstream(ctx, branchToSubmit, upstream); err != nil {
|
||||
log.Warn("Could not set upstream", "branch", branchToSubmit, "remote", remote, "error", err)
|
||||
log.Warn("Could not set upstream", "branch", branchToSubmit, "remote", remote.Push, "error", err)
|
||||
}
|
||||
|
||||
if prepared != nil {
|
||||
@@ -982,7 +1064,7 @@ func (h *Handler) submitBranch(
|
||||
|
||||
if pull.HeadHash != commitHash {
|
||||
pushOpts := git.PushOptions{
|
||||
Remote: remote,
|
||||
Remote: remote.Push,
|
||||
Refspec: git.Refspec(
|
||||
commitHash.String() + ":refs/heads/" + upstreamBranch,
|
||||
),
|
||||
@@ -992,7 +1074,7 @@ func (h *Handler) submitBranch(
|
||||
if !opts.Force {
|
||||
// Force push, but only if the ref is exactly
|
||||
// where we think it is.
|
||||
existingHash, err := h.Repository.PeelToCommit(ctx, remote+"/"+upstreamBranch)
|
||||
existingHash, err := h.Repository.PeelToCommit(ctx, remote.Push+"/"+upstreamBranch)
|
||||
if err == nil {
|
||||
pushOpts.ForceWithLease = upstreamBranch + ":" + existingHash.String()
|
||||
}
|
||||
@@ -1014,7 +1096,7 @@ func (h *Handler) submitBranch(
|
||||
}
|
||||
|
||||
// remoteRepo is guaranteed to be available at this point.
|
||||
remoteRepo, err := h.RemoteRepository(ctx)
|
||||
remoteRepo, err := h.upstreamRepository(ctx)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("edit CR %v: %w", pull.ID, err)
|
||||
}
|
||||
@@ -1076,6 +1158,7 @@ func (h *Handler) prepareBranch(
|
||||
remoteName string,
|
||||
remoteRepo forge.Repository,
|
||||
upstreamBranch, baseBranch, upstreamBase string,
|
||||
pushRepository forge.RepositoryID,
|
||||
opts *submitOptions,
|
||||
) (*preparedBranch, error) {
|
||||
// Fetch the template while we're prompting the other fields.
|
||||
@@ -1249,6 +1332,7 @@ func (h *Handler) prepareBranch(
|
||||
draft: draft,
|
||||
head: upstreamBranch,
|
||||
base: upstreamBase,
|
||||
pushRepository: pushRepository,
|
||||
remoteRepo: remoteRepo,
|
||||
store: h.Store,
|
||||
log: h.Log,
|
||||
@@ -1297,21 +1381,23 @@ type preparedBranch struct {
|
||||
reviewers []string
|
||||
assignees []string
|
||||
|
||||
remoteRepo forge.Repository
|
||||
store Store
|
||||
log *silog.Logger
|
||||
pushRepository forge.RepositoryID
|
||||
remoteRepo forge.Repository
|
||||
store Store
|
||||
log *silog.Logger
|
||||
}
|
||||
|
||||
func (b *preparedBranch) Publish(ctx context.Context) (forge.ChangeID, string, error) {
|
||||
result, err := b.remoteRepo.SubmitChange(ctx, forge.SubmitChangeRequest{
|
||||
Subject: b.Subject,
|
||||
Body: b.Body,
|
||||
Head: b.head,
|
||||
Base: b.base,
|
||||
Draft: b.draft,
|
||||
Labels: b.labels,
|
||||
Reviewers: b.reviewers,
|
||||
Assignees: b.assignees,
|
||||
Subject: b.Subject,
|
||||
Body: b.Body,
|
||||
Head: b.head,
|
||||
Base: b.base,
|
||||
PushRepository: b.pushRepository,
|
||||
Draft: b.draft,
|
||||
Labels: b.labels,
|
||||
Reviewers: b.reviewers,
|
||||
Assignees: b.assignees,
|
||||
})
|
||||
if err != nil {
|
||||
// If the branch could not be submitted because the base branch
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package submit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
@@ -8,8 +9,13 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.abhg.dev/gs/internal/browser"
|
||||
"go.abhg.dev/gs/internal/forge"
|
||||
"go.abhg.dev/gs/internal/forge/forgetest"
|
||||
"go.abhg.dev/gs/internal/silog"
|
||||
"go.abhg.dev/gs/internal/silog/silogtest"
|
||||
"go.abhg.dev/gs/internal/spice/state"
|
||||
"go.abhg.dev/gs/internal/ui"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
@@ -56,6 +62,58 @@ func TestBranchSubmit_listChangeTemplates(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandler_pushRepositoryID_rejectsDifferentForge(t *testing.T) {
|
||||
mockCtrl := gomock.NewController(t)
|
||||
|
||||
upstreamForge := forgetest.NewMockForge(mockCtrl)
|
||||
upstreamForge.EXPECT().
|
||||
ID().
|
||||
Return("github").
|
||||
AnyTimes()
|
||||
|
||||
pushForge := forgetest.NewMockForge(mockCtrl)
|
||||
pushForge.EXPECT().
|
||||
ID().
|
||||
Return("gitlab").
|
||||
AnyTimes()
|
||||
|
||||
handler := &Handler{
|
||||
Log: silog.Nop(),
|
||||
View: ui.NewFileView(&bytes.Buffer{}),
|
||||
Repository: nil,
|
||||
Worktree: nil,
|
||||
Store: NewMockStore(mockCtrl),
|
||||
Service: NewMockService(mockCtrl),
|
||||
Browser: &browser.Noop{},
|
||||
FindRemote: func(context.Context) (state.Remote, error) {
|
||||
return state.Remote{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
}, nil
|
||||
},
|
||||
OpenRepository: func(context.Context, forge.Forge, forge.RepositoryID) (forge.Repository, error) {
|
||||
return nil, assert.AnError
|
||||
},
|
||||
ResolveRepository: func(
|
||||
_ context.Context,
|
||||
remote string,
|
||||
) (forge.Forge, forge.RepositoryID, error) {
|
||||
switch remote {
|
||||
case "upstream":
|
||||
return upstreamForge, stubRepositoryID("alice/repo"), nil
|
||||
case "origin":
|
||||
return pushForge, stubRepositoryID("bob/repo"), nil
|
||||
default:
|
||||
return nil, nil, assert.AnError
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_, err := handler.pushRepositoryID(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "different forge")
|
||||
}
|
||||
|
||||
func TestReviewersAddWhen_UnmarshalText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -193,3 +251,13 @@ func TestEffectiveReviewers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type stubRepositoryID string
|
||||
|
||||
func (id stubRepositoryID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
func (id stubRepositoryID) ChangeURL(forge.ChangeID) string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,9 @@ type Handler struct {
|
||||
Remote string // required
|
||||
// RemoteRepository is set only if remote refers to a supported forge.
|
||||
RemoteRepository forge.Repository // optional
|
||||
// PushRepository identifies the repository that owns pushed branches.
|
||||
// If nil, pushed branches are expected to live in RemoteRepository.
|
||||
PushRepository forge.RepositoryID // optional
|
||||
}
|
||||
|
||||
// ClosedChanges specifies how to handle closed Change Requests.
|
||||
@@ -590,7 +593,8 @@ func (h *Handler) findForgeFinishedBranches(
|
||||
wg.Go(func() {
|
||||
for b := range trackedch {
|
||||
changes, err := h.RemoteRepository.FindChangesByBranch(ctx, b.Name, forge.FindChangesOptions{
|
||||
Limit: 10,
|
||||
PushRepository: h.PushRepository,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
h.Log.Error("Failed to list changes", "branch", b.Name, "error", err)
|
||||
|
||||
@@ -216,8 +216,11 @@ func (s *Service) verifyUpstreamBranchRef(ctx context.Context, branch, upstreamB
|
||||
if err != nil {
|
||||
return false, nil // no remote, no upstream branch
|
||||
}
|
||||
if remote.Push == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
upstreamRef := remote + "/" + upstreamBranch
|
||||
upstreamRef := remote.Push + "/" + upstreamBranch
|
||||
if _, err := s.repo.PeelToCommit(ctx, upstreamRef); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestService_LookupBranch_changeAssociation(t *testing.T) {
|
||||
|
||||
mockStore.EXPECT().
|
||||
Remote().
|
||||
Return("", git.ErrNotExist).
|
||||
Return(state.Remote{}, git.ErrNotExist).
|
||||
AnyTimes()
|
||||
|
||||
mockRepo.EXPECT().
|
||||
@@ -152,10 +152,13 @@ func TestService_LookupBranch_upstreamBranch(t *testing.T) {
|
||||
|
||||
// Use in-memory storage backend and real store.
|
||||
store, err := state.InitStore(ctx, state.InitStoreRequest{
|
||||
DB: storage.NewDB(make(storage.MapBackend)),
|
||||
Trunk: "main",
|
||||
Remote: "origin",
|
||||
Log: silogtest.New(t),
|
||||
DB: storage.NewDB(make(storage.MapBackend)),
|
||||
Trunk: "main",
|
||||
Remote: state.Remote{
|
||||
Upstream: "origin",
|
||||
Push: "origin",
|
||||
},
|
||||
Log: silogtest.New(t),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -273,6 +276,24 @@ func TestService_LookupBranch_upstreamBranch(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, lookup.UpstreamBranch)
|
||||
})
|
||||
|
||||
t.Run("ForkModeUsesPushRemote", func(t *testing.T) {
|
||||
require.NoError(t, store.SetRemote(ctx, state.Remote{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
}))
|
||||
setUpstreamBranch("feature")
|
||||
|
||||
// Submitted branches are pushed to the push remote in fork mode,
|
||||
// so the upstream branch name must be validated there.
|
||||
mockRepo.EXPECT().
|
||||
PeelToCommit(gomock.Any(), "origin/feature").
|
||||
Return(git.Hash("def123"), nil)
|
||||
|
||||
resp, err := svc.LookupBranch(ctx, "feature")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "feature", resp.UpstreamBranch)
|
||||
})
|
||||
}
|
||||
|
||||
// Regression test for https://github.com/abhinav/git-spice/issues/926
|
||||
|
||||
+17
-7
@@ -17,7 +17,8 @@ type GuessOp int
|
||||
// List of guess operations.
|
||||
const (
|
||||
GuessUnknown GuessOp = iota
|
||||
GuessRemote
|
||||
GuessUpstreamRemote
|
||||
GuessPushRemote
|
||||
GuessTrunk
|
||||
)
|
||||
|
||||
@@ -27,16 +28,25 @@ type Guesser struct {
|
||||
// Select prompts a user to select from a list of options
|
||||
// and returns the selected option.
|
||||
//
|
||||
// selected is the the option that should be selected by default
|
||||
// selected is the option that should be selected by default
|
||||
// or an empty string if there's no preferred default.
|
||||
Select func(op GuessOp, opts []string, selected string) (string, error) // required
|
||||
}
|
||||
|
||||
// GuessRemote attempts to guess the name of the remote
|
||||
// to use for the repository.
|
||||
// GuessUpstreamRemote attempts to guess the upstream remote.
|
||||
func (g *Guesser) GuessUpstreamRemote(ctx context.Context, repo GitRepository) (string, error) {
|
||||
return g.guessRemote(ctx, repo, GuessUpstreamRemote, "")
|
||||
}
|
||||
|
||||
// GuessPushRemote attempts to guess the push remote.
|
||||
//
|
||||
// It returns an empty string if a remote was not found.
|
||||
func (g *Guesser) GuessRemote(ctx context.Context, repo GitRepository) (string, error) {
|
||||
// selected is the remote that should be selected by default
|
||||
// if prompting is necessary.
|
||||
func (g *Guesser) GuessPushRemote(ctx context.Context, repo GitRepository, selected string) (string, error) {
|
||||
return g.guessRemote(ctx, repo, GuessPushRemote, selected)
|
||||
}
|
||||
|
||||
func (g *Guesser) guessRemote(ctx context.Context, repo GitRepository, op GuessOp, selected string) (string, error) {
|
||||
remotes, err := repo.ListRemotes(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list remotes: %w", err)
|
||||
@@ -48,7 +58,7 @@ func (g *Guesser) GuessRemote(ctx context.Context, repo GitRepository) (string,
|
||||
case 1:
|
||||
return remotes[0], nil
|
||||
default:
|
||||
remote, err := g.Select(GuessRemote, remotes, "")
|
||||
remote, err := g.Select(op, remotes, selected)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("prompt for remote: %w", err)
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -388,10 +388,10 @@ func (mr *MockStoreMockRecorder) LookupBranch(ctx, name any) *gomock.Call {
|
||||
}
|
||||
|
||||
// Remote mocks base method.
|
||||
func (m *MockStore) Remote() (string, error) {
|
||||
func (m *MockStore) Remote() (state.Remote, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Remote")
|
||||
ret0, _ := ret[0].(string)
|
||||
ret0, _ := ret[0].(state.Remote)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ var (
|
||||
type Store interface {
|
||||
// Trunk returns the name of the trunk branch.
|
||||
Trunk() string
|
||||
Remote() (string, error)
|
||||
Remote() (state.Remote, error)
|
||||
|
||||
// LookupBranch returns the branch state for the given branch,
|
||||
// or [state.ErrNotExist] if the branch does not exist.
|
||||
|
||||
+122
-11
@@ -1,16 +1,114 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.abhg.dev/gs/internal/spice/state/storage"
|
||||
)
|
||||
|
||||
const _repoJSON = "repo"
|
||||
|
||||
// Remote identifies the Git remotes used by git-spice.
|
||||
type Remote struct {
|
||||
// Upstream is the remote that hosts trunk and change requests.
|
||||
Upstream string
|
||||
|
||||
// Push is the remote that receives submitted branch pushes.
|
||||
Push string
|
||||
}
|
||||
|
||||
// ForkMode reports whether the repository uses different remotes
|
||||
// for upstream operations and branch pushes.
|
||||
func (r Remote) ForkMode() bool {
|
||||
return r.Upstream != "" && r.Push != "" && r.Upstream != r.Push
|
||||
}
|
||||
|
||||
type remoteInfo struct {
|
||||
Upstream string `json:"upstream,omitempty"`
|
||||
Push string `json:"push,omitempty"`
|
||||
}
|
||||
|
||||
func newRemoteInfo(remote Remote) remoteInfo {
|
||||
return remoteInfo(remote)
|
||||
}
|
||||
|
||||
type repoInfo struct {
|
||||
Trunk string `json:"trunk"`
|
||||
Remote string `json:"remote"`
|
||||
Trunk string `json:"trunk"`
|
||||
Remote string `json:"remote,omitempty"`
|
||||
Remotes *remoteInfo `json:"remotes,omitempty"`
|
||||
}
|
||||
|
||||
func newRepoInfo(trunk string, remote Remote) repoInfo {
|
||||
info := repoInfo{
|
||||
Trunk: trunk,
|
||||
}
|
||||
|
||||
switch {
|
||||
case remote == (Remote{}):
|
||||
// No remote configured.
|
||||
case remote.ForkMode():
|
||||
// Older binaries must not guess at fork-mode semantics.
|
||||
// The version file gates this v2-only field.
|
||||
info.Remote = remote.Upstream
|
||||
remotes := newRemoteInfo(remote)
|
||||
info.Remotes = &remotes
|
||||
default:
|
||||
info.Remote = cmp.Or(remote.Upstream, remote.Push)
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func (i *repoInfo) stateRemote() Remote {
|
||||
if r := i.Remotes; r != nil {
|
||||
return Remote{
|
||||
Upstream: r.Upstream,
|
||||
Push: r.Push,
|
||||
}
|
||||
}
|
||||
if i.Remote == "" {
|
||||
return Remote{}
|
||||
}
|
||||
return Remote{
|
||||
Upstream: i.Remote,
|
||||
Push: i.Remote,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *repoInfo) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Trunk string `json:"trunk"`
|
||||
Remote json.RawMessage `json:"remote"`
|
||||
Remotes *remoteInfo `json:"remotes"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.Trunk = raw.Trunk
|
||||
i.Remotes = raw.Remotes
|
||||
if len(raw.Remote) == 0 || string(raw.Remote) == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var legacy string
|
||||
if err := json.Unmarshal(raw.Remote, &legacy); err == nil {
|
||||
i.Remote = legacy
|
||||
return nil
|
||||
}
|
||||
|
||||
var previous remoteInfo
|
||||
if err := json.Unmarshal(raw.Remote, &previous); err != nil {
|
||||
return fmt.Errorf("unmarshal remote: %w", err)
|
||||
}
|
||||
if previous != (remoteInfo{}) && i.Remotes == nil {
|
||||
i.Remotes = &previous
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *repoInfo) Validate() error {
|
||||
@@ -25,23 +123,23 @@ func (s *Store) Trunk() string {
|
||||
return s.trunk
|
||||
}
|
||||
|
||||
// Remote returns the remote configured for the repository.
|
||||
// Remote returns the remotes configured for the repository.
|
||||
// Returns [ErrNotExist] if no remote is configured.
|
||||
func (s *Store) Remote() (string, error) {
|
||||
if s.remote == "" {
|
||||
return "", ErrNotExist
|
||||
func (s *Store) Remote() (Remote, error) {
|
||||
if s.remote == (Remote{}) {
|
||||
return Remote{}, ErrNotExist
|
||||
}
|
||||
|
||||
return s.remote, nil
|
||||
}
|
||||
|
||||
// SetRemote changes teh remote name configured for the repository.
|
||||
func (s *Store) SetRemote(ctx context.Context, remote string) error {
|
||||
// SetRemote changes the remotes configured for the repository.
|
||||
func (s *Store) SetRemote(ctx context.Context, remote Remote) error {
|
||||
var info repoInfo
|
||||
if err := s.db.Get(ctx, _repoJSON, &info); err != nil {
|
||||
return fmt.Errorf("get repo info: %w", err)
|
||||
}
|
||||
info.Remote = remote
|
||||
info = newRepoInfo(info.Trunk, remote)
|
||||
|
||||
if err := info.Validate(); err != nil {
|
||||
// Technically impossible if state was already validated
|
||||
@@ -49,10 +147,23 @@ func (s *Store) SetRemote(ctx context.Context, remote string) error {
|
||||
return fmt.Errorf("would corrupt state: %w", err)
|
||||
}
|
||||
|
||||
err := s.db.Set(ctx, _repoJSON, info, fmt.Sprintf("set remote: %v", remote))
|
||||
if err != nil {
|
||||
version := storageVersionForRemote(remote)
|
||||
if err := s.db.Update(ctx, storage.UpdateRequest{
|
||||
Sets: []storage.SetRequest{
|
||||
{
|
||||
Key: _repoJSON,
|
||||
Value: info,
|
||||
},
|
||||
{
|
||||
Key: _versionFile,
|
||||
Value: version,
|
||||
},
|
||||
},
|
||||
Message: fmt.Sprintf("set remote: %v", remote),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("update: %w", err)
|
||||
}
|
||||
s.remote = remote
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,13 +1,105 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepoinfoValidate(t *testing.T) {
|
||||
func TestRepoInfoUnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
give string
|
||||
want repoInfo
|
||||
}{
|
||||
{
|
||||
name: "legacy string",
|
||||
give: `{"trunk":"main","remote":"origin"}`,
|
||||
want: repoInfo{
|
||||
Trunk: "main",
|
||||
Remote: "origin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remotes object",
|
||||
give: `{"trunk":"main","remotes":{"upstream":"upstream","push":"origin"}}`,
|
||||
want: repoInfo{
|
||||
Trunk: "main",
|
||||
Remotes: &remoteInfo{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "previous remote object",
|
||||
give: `{"trunk":"main","remote":{"upstream":"upstream","push":"origin"}}`,
|
||||
want: repoInfo{
|
||||
Trunk: "main",
|
||||
Remotes: &remoteInfo{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty remote string",
|
||||
give: `{"trunk":"main","remote":""}`,
|
||||
want: repoInfo{Trunk: "main"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got repoInfo
|
||||
require.NoError(t, json.Unmarshal([]byte(tt.give), &got))
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteForkMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
give Remote
|
||||
want bool
|
||||
}{
|
||||
{name: "empty"},
|
||||
{
|
||||
name: "same",
|
||||
give: Remote{
|
||||
Upstream: "origin",
|
||||
Push: "origin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "different",
|
||||
give: Remote{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "missing push",
|
||||
give: Remote{Upstream: "upstream"},
|
||||
},
|
||||
{
|
||||
name: "missing upstream",
|
||||
give: Remote{Push: "origin"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, tt.give.ForkMode())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoInfoValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
give repoInfo
|
||||
@@ -24,7 +116,13 @@ func TestRepoinfoValidate(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "valid with remote",
|
||||
give: repoInfo{Trunk: "main", Remote: "origin"},
|
||||
give: repoInfo{
|
||||
Trunk: "main",
|
||||
Remotes: &remoteInfo{
|
||||
Upstream: "origin",
|
||||
Push: "origin",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,13 @@ func NewMemoryStore(t testing.TB, trunk, remote string, log *silog.Logger) *stat
|
||||
}
|
||||
|
||||
store, err := state.InitStore(t.Context(), state.InitStoreRequest{
|
||||
DB: db,
|
||||
Trunk: cmp.Or(trunk, "main"),
|
||||
Remote: remote,
|
||||
Log: log,
|
||||
DB: db,
|
||||
Trunk: cmp.Or(trunk, "main"),
|
||||
Remote: state.Remote{
|
||||
Upstream: remote,
|
||||
Push: remote,
|
||||
},
|
||||
Log: log,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize store: %v", err)
|
||||
|
||||
@@ -31,7 +31,7 @@ type Store struct {
|
||||
log *silog.Logger
|
||||
|
||||
trunk string
|
||||
remote string
|
||||
remote Remote
|
||||
}
|
||||
|
||||
// InitStoreRequest is a request to initialize the store
|
||||
@@ -43,12 +43,11 @@ type InitStoreRequest struct {
|
||||
// e.g. "main" or "master".
|
||||
Trunk string
|
||||
|
||||
// Remote is the name of the remote to use for pushing and pulling.
|
||||
// e.g. "origin" or "upstream".
|
||||
// Remote identifies the remotes to use for pushing and pulling.
|
||||
//
|
||||
// If empty, a remote will not be configured and push/pull
|
||||
// operations will not be available.
|
||||
Remote string
|
||||
// If empty, a remote will not be configured and push/pull operations
|
||||
// will not be available.
|
||||
Remote Remote
|
||||
|
||||
// Reset indicates that the store's state should be nuked
|
||||
// if it's already initialized.
|
||||
@@ -109,15 +108,12 @@ func InitStore(ctx context.Context, req InitStoreRequest) (*Store, error) {
|
||||
update := storage.UpdateRequest{
|
||||
Sets: []storage.SetRequest{
|
||||
{
|
||||
Key: _repoJSON,
|
||||
Value: repoInfo{
|
||||
Trunk: req.Trunk,
|
||||
Remote: req.Remote,
|
||||
},
|
||||
Key: _repoJSON,
|
||||
Value: newRepoInfo(req.Trunk, store.remote),
|
||||
},
|
||||
{
|
||||
Key: _versionFile,
|
||||
Value: LatestVersion,
|
||||
Value: storageVersionForRemote(store.remote),
|
||||
},
|
||||
},
|
||||
Message: "initialize store",
|
||||
@@ -178,7 +174,11 @@ func OpenStore(ctx context.Context, db DB, logger *silog.Logger) (*Store, error)
|
||||
logger = silog.Nop()
|
||||
}
|
||||
|
||||
if err := checkVersion(ctx, db); err != nil {
|
||||
version, err := loadVersion(ctx, db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load store version: %w", err)
|
||||
}
|
||||
if err := checkVersion(version); err != nil {
|
||||
return nil, fmt.Errorf("check store layout: %w", err)
|
||||
}
|
||||
|
||||
@@ -197,7 +197,14 @@ func OpenStore(ctx context.Context, db DB, logger *silog.Logger) (*Store, error)
|
||||
return &Store{
|
||||
db: db,
|
||||
trunk: info.Trunk,
|
||||
remote: info.Remote,
|
||||
remote: info.stateRemote(),
|
||||
log: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func storageVersionForRemote(remote Remote) Version {
|
||||
if remote.ForkMode() {
|
||||
return VersionTwo
|
||||
}
|
||||
return VersionOne
|
||||
}
|
||||
|
||||
@@ -129,6 +129,245 @@ func TestStore(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenStore_remoteMigration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mem storage.MapBackend
|
||||
want state.Remote
|
||||
wantVersion string
|
||||
wantRepo string
|
||||
}{
|
||||
{
|
||||
name: "ImplicitV1",
|
||||
mem: storage.MapBackend{
|
||||
"repo": []byte(`{"trunk":"main","remote":"origin"}`),
|
||||
},
|
||||
want: state.Remote{
|
||||
Upstream: "origin",
|
||||
Push: "origin",
|
||||
},
|
||||
wantRepo: `{"trunk":"main","remote":"origin"}`,
|
||||
},
|
||||
{
|
||||
name: "ExplicitV1",
|
||||
mem: storage.MapBackend{
|
||||
"version": []byte("1"),
|
||||
"repo": []byte(`{"trunk":"main","remote":"origin"}`),
|
||||
},
|
||||
want: state.Remote{
|
||||
Upstream: "origin",
|
||||
Push: "origin",
|
||||
},
|
||||
wantVersion: `1`,
|
||||
wantRepo: `{"trunk":"main","remote":"origin"}`,
|
||||
},
|
||||
{
|
||||
name: "ExplicitV2",
|
||||
mem: storage.MapBackend{
|
||||
"version": []byte("2"),
|
||||
"repo": []byte(
|
||||
`{"trunk":"main","remotes":{"upstream":"upstream","push":"origin"}}`,
|
||||
),
|
||||
},
|
||||
want: state.Remote{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
},
|
||||
wantVersion: `2`,
|
||||
wantRepo: `{
|
||||
"trunk": "main",
|
||||
"remotes": {
|
||||
"upstream": "upstream",
|
||||
"push": "origin"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "PreviousV2RemoteObject",
|
||||
mem: storage.MapBackend{
|
||||
"version": []byte("2"),
|
||||
"repo": []byte(
|
||||
`{"trunk":"main","remote":{"upstream":"upstream","push":"origin"}}`,
|
||||
),
|
||||
},
|
||||
want: state.Remote{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
},
|
||||
wantVersion: `2`,
|
||||
wantRepo: `{
|
||||
"trunk": "main",
|
||||
"remote": {
|
||||
"upstream": "upstream",
|
||||
"push": "origin"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "OmittedRemote",
|
||||
mem: storage.MapBackend{
|
||||
"repo": []byte(`{"trunk":"main"}`),
|
||||
},
|
||||
wantRepo: `{"trunk":"main"}`,
|
||||
},
|
||||
{
|
||||
name: "EmptyLegacyRemote",
|
||||
mem: storage.MapBackend{
|
||||
"repo": []byte(`{"trunk":"main","remote":""}`),
|
||||
},
|
||||
wantRepo: `{"trunk":"main","remote":""}`,
|
||||
},
|
||||
{
|
||||
name: "EmptyRemoteObject",
|
||||
mem: storage.MapBackend{
|
||||
"version": []byte("2"),
|
||||
"repo": []byte(`{"trunk":"main","remote":{}}`),
|
||||
},
|
||||
wantVersion: `2`,
|
||||
wantRepo: `{"trunk":"main","remote":{}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
store, err := state.OpenStore(
|
||||
t.Context(),
|
||||
storage.NewDB(tt.mem),
|
||||
silogtest.New(t),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := store.Remote()
|
||||
if tt.want == (state.Remote{}) {
|
||||
require.ErrorIs(t, err, state.ErrNotExist)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
}
|
||||
|
||||
if tt.wantVersion == "" {
|
||||
assert.Empty(t, tt.mem["version"])
|
||||
} else {
|
||||
assert.JSONEq(t, tt.wantVersion, string(tt.mem["version"]))
|
||||
}
|
||||
assert.JSONEq(t, tt.wantRepo, string(tt.mem["repo"]))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenStore_remoteMigrationMalformed(t *testing.T) {
|
||||
mem := storage.MapBackend{
|
||||
"repo": []byte(`{"trunk":"main","remote":1}`),
|
||||
}
|
||||
|
||||
_, err := state.OpenStore(t.Context(), storage.NewDB(mem), nil)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "get repo state:")
|
||||
}
|
||||
|
||||
func TestInitStore_writesVersionOneForSameRemote(t *testing.T) {
|
||||
mem := make(storage.MapBackend)
|
||||
_, err := state.InitStore(t.Context(), state.InitStoreRequest{
|
||||
DB: storage.NewDB(mem),
|
||||
Trunk: "main",
|
||||
Remote: state.Remote{
|
||||
Upstream: "origin",
|
||||
Push: "origin",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.JSONEq(t, `1`, string(mem["version"]))
|
||||
assert.JSONEq(t, `{
|
||||
"trunk": "main",
|
||||
"remote": "origin"
|
||||
}`, string(mem["repo"]))
|
||||
}
|
||||
|
||||
func TestInitStore_writesVersionTwoRemotesObjectForForkMode(t *testing.T) {
|
||||
mem := make(storage.MapBackend)
|
||||
_, err := state.InitStore(t.Context(), state.InitStoreRequest{
|
||||
DB: storage.NewDB(mem),
|
||||
Trunk: "main",
|
||||
Remote: state.Remote{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.JSONEq(t, `2`, string(mem["version"]))
|
||||
assert.JSONEq(t, `{
|
||||
"trunk": "main",
|
||||
"remote": "upstream",
|
||||
"remotes": {
|
||||
"upstream": "upstream",
|
||||
"push": "origin"
|
||||
}
|
||||
}`, string(mem["repo"]))
|
||||
}
|
||||
|
||||
func TestStore_SetRemote(t *testing.T) {
|
||||
mem := storage.MapBackend{
|
||||
"repo": []byte(`{"trunk":"main","remote":"origin"}`),
|
||||
}
|
||||
store, err := state.OpenStore(t.Context(), storage.NewDB(mem), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.SetRemote(t.Context(), state.Remote{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.JSONEq(t, `2`, string(mem["version"]))
|
||||
assert.JSONEq(t, `{
|
||||
"trunk": "main",
|
||||
"remote": "upstream",
|
||||
"remotes": {
|
||||
"upstream": "upstream",
|
||||
"push": "origin"
|
||||
}
|
||||
}`, string(mem["repo"]))
|
||||
|
||||
got, err := store.Remote()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, state.Remote{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestStore_SetRemote_downgradesToVersionOneForSameRemote(t *testing.T) {
|
||||
mem := storage.MapBackend{
|
||||
"version": []byte("2"),
|
||||
"repo": []byte(
|
||||
`{"trunk":"main","remote":"upstream","remotes":{"upstream":"upstream","push":"origin"}}`,
|
||||
),
|
||||
}
|
||||
store, err := state.OpenStore(t.Context(), storage.NewDB(mem), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.SetRemote(t.Context(), state.Remote{
|
||||
Upstream: "origin",
|
||||
Push: "origin",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.JSONEq(t, `1`, string(mem["version"]))
|
||||
assert.JSONEq(t, `{
|
||||
"trunk": "main",
|
||||
"remote": "origin"
|
||||
}`, string(mem["repo"]))
|
||||
|
||||
got, err := store.Remote()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, state.Remote{
|
||||
Upstream: "origin",
|
||||
Push: "origin",
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestOpenStore_errors(t *testing.T) {
|
||||
t.Run("VersionMismatch", func(t *testing.T) {
|
||||
mem := storage.MapBackend{
|
||||
|
||||
@@ -17,23 +17,18 @@ type Version int
|
||||
// Supported versions of the storage layout.
|
||||
const (
|
||||
VersionOne Version = 1
|
||||
VersionTwo Version = 2
|
||||
|
||||
// LatestVersion refers to the latest supported version.
|
||||
LatestVersion = VersionOne
|
||||
LatestVersion = VersionTwo
|
||||
)
|
||||
|
||||
// checkVersion verifies that the given DB
|
||||
// uses a supported version of the layout.
|
||||
func checkVersion(ctx context.Context, db DB) error {
|
||||
version, err := loadVersion(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load store version: %w", err)
|
||||
}
|
||||
|
||||
// checkVersion verifies that the given version is supported.
|
||||
func checkVersion(version Version) error {
|
||||
// If/when we make a breaking change to the storage format,
|
||||
// we'll add migration code here.
|
||||
switch version {
|
||||
case VersionOne:
|
||||
case VersionOne, VersionTwo:
|
||||
// ok
|
||||
|
||||
default:
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.abhg.dev/gs/internal/spice/state/storage"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func TestLoadVersion(t *testing.T) {
|
||||
@@ -26,6 +25,13 @@ func TestLoadVersion(t *testing.T) {
|
||||
},
|
||||
want: VersionOne,
|
||||
},
|
||||
{
|
||||
name: "ExplicitV2",
|
||||
files: storage.MapBackend{
|
||||
"version": []byte("2"),
|
||||
},
|
||||
want: VersionTwo,
|
||||
},
|
||||
{
|
||||
name: "FutureVersion",
|
||||
files: storage.MapBackend{
|
||||
@@ -47,30 +53,22 @@ func TestLoadVersion(t *testing.T) {
|
||||
|
||||
func TestCheckVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files storage.MapBackend
|
||||
err bool
|
||||
name string
|
||||
version Version
|
||||
err bool
|
||||
}{
|
||||
{name: "ImplicitV1"},
|
||||
{name: "VersionOne", version: VersionOne},
|
||||
{name: "VersionTwo", version: VersionTwo},
|
||||
{
|
||||
name: "ExplicitV1",
|
||||
files: storage.MapBackend{
|
||||
"version": []byte("1"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "UnsupportedVersion",
|
||||
files: storage.MapBackend{
|
||||
"version": []byte("500"),
|
||||
},
|
||||
err: true,
|
||||
name: "UnsupportedVersion",
|
||||
version: Version(500),
|
||||
err: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db := storage.NewDB(tt.files)
|
||||
err := checkVersion(t.Context(), db)
|
||||
err := checkVersion(tt.version)
|
||||
if tt.err {
|
||||
require.Error(t, err)
|
||||
assert.ErrorAs(t, err, new(*VersionMismatchError))
|
||||
@@ -81,20 +79,6 @@ func TestCheckVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckVersion_loadError(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mockDB := NewMockDB(ctrl)
|
||||
|
||||
mockDB.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(assert.AnError)
|
||||
|
||||
err := checkVersion(t.Context(), mockDB)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "load store version:")
|
||||
assert.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
func TestVersionMismatchError(t *testing.T) {
|
||||
err := &VersionMismatchError{
|
||||
Want: 42,
|
||||
|
||||
@@ -446,11 +446,14 @@ func (cmd *mainCmd) AfterApply(ctx context.Context, kctx *kong.Context, logger *
|
||||
Store: store,
|
||||
Service: svc,
|
||||
Browser: _browserLauncher,
|
||||
FindRemote: func(ctx context.Context) (string, error) {
|
||||
FindRemote: func(ctx context.Context) (state.Remote, error) {
|
||||
return ensureRemote(ctx, wt.Repository(), store, log, view)
|
||||
},
|
||||
OpenRemoteRepository: func(ctx context.Context, remote string) (forge.Repository, error) {
|
||||
return openRemoteRepository(ctx, log, secretStash, forges, wt.Repository(), remote)
|
||||
ResolveRepository: func(ctx context.Context, remote string) (forge.Forge, forge.RepositoryID, error) {
|
||||
return resolveRemoteRepository(ctx, log, forges, wt.Repository(), remote)
|
||||
},
|
||||
OpenRepository: func(ctx context.Context, f forge.Forge, repo forge.RepositoryID) (forge.Repository, error) {
|
||||
return openRepository(ctx, log, secretStash, f, repo)
|
||||
},
|
||||
}, nil
|
||||
}),
|
||||
@@ -551,7 +554,7 @@ func (cmd *mainCmd) AfterApply(ctx context.Context, kctx *kong.Context, logger *
|
||||
return nil, err
|
||||
}
|
||||
|
||||
remoteRepo, err := openRemoteRepositorySilent(ctx, secretStash, forges, repo, remote)
|
||||
remoteRepo, err := openRemoteRepositorySilent(ctx, secretStash, forges, repo, remote.Upstream)
|
||||
if err != nil {
|
||||
var unsupported *unsupportedForgeError
|
||||
if !errors.As(err, &unsupported) {
|
||||
@@ -560,6 +563,14 @@ func (cmd *mainCmd) AfterApply(ctx context.Context, kctx *kong.Context, logger *
|
||||
remoteRepo = nil
|
||||
}
|
||||
|
||||
var pushRepository forge.RepositoryID
|
||||
if remote.ForkMode() {
|
||||
pushRepository, err = resolveRemoteRepositoryID(ctx, forges, repo, remote.Push)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve push repository: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &sync.Handler{
|
||||
Log: log,
|
||||
View: view,
|
||||
@@ -570,8 +581,9 @@ func (cmd *mainCmd) AfterApply(ctx context.Context, kctx *kong.Context, logger *
|
||||
Delete: deleteHandler,
|
||||
Restack: restackHandler,
|
||||
Autostash: autostashHandler,
|
||||
Remote: remote,
|
||||
Remote: remote.Upstream,
|
||||
RemoteRepository: remoteRepo,
|
||||
PushRepository: pushRepository,
|
||||
}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -44,20 +44,51 @@ func openRemoteRepositorySilent(
|
||||
gitRepo *git.Repository,
|
||||
remote string,
|
||||
) (forge.Repository, error) {
|
||||
f, repoID, err := resolveRemoteRepositorySilent(ctx, forges, gitRepo, remote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return openForgeRepository(ctx, stash, f, repoID)
|
||||
}
|
||||
|
||||
func resolveRemoteRepositoryID(
|
||||
ctx context.Context,
|
||||
forges *forge.Registry,
|
||||
gitRepo *git.Repository,
|
||||
remote string,
|
||||
) (forge.RepositoryID, error) {
|
||||
remoteURL, err := gitRepo.RemoteURL(ctx, remote)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get remote URL: %w", err)
|
||||
}
|
||||
|
||||
_, repoID, ok := forge.MatchRemoteURL(forges, remoteURL)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no forge matches remote URL %q", remoteURL)
|
||||
}
|
||||
return repoID, nil
|
||||
}
|
||||
|
||||
func resolveRemoteRepositorySilent(
|
||||
ctx context.Context,
|
||||
forges *forge.Registry,
|
||||
gitRepo *git.Repository,
|
||||
remote string,
|
||||
) (forge.Forge, forge.RepositoryID, error) {
|
||||
remoteURL, err := gitRepo.RemoteURL(ctx, remote)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("get remote URL: %w", err)
|
||||
}
|
||||
|
||||
f, repoID, ok := forge.MatchRemoteURL(forges, remoteURL)
|
||||
if !ok {
|
||||
return nil, &unsupportedForgeError{
|
||||
return nil, nil, &unsupportedForgeError{
|
||||
Remote: remote,
|
||||
RemoteURL: remoteURL,
|
||||
}
|
||||
}
|
||||
|
||||
return openForgeRepository(ctx, stash, f, repoID)
|
||||
return f, repoID, nil
|
||||
}
|
||||
|
||||
func openForgeRepository(
|
||||
@@ -77,33 +108,37 @@ func openForgeRepository(
|
||||
return f.OpenRepository(ctx, tok, repoID)
|
||||
}
|
||||
|
||||
func openRemoteRepository(
|
||||
func resolveRemoteRepository(
|
||||
ctx context.Context,
|
||||
log *silog.Logger,
|
||||
stash secret.Stash,
|
||||
forges *forge.Registry,
|
||||
gitRepo *git.Repository,
|
||||
remote string,
|
||||
) (forge.Repository, error) {
|
||||
forgeRepo, err := openRemoteRepositorySilent(ctx, stash, forges, gitRepo, remote)
|
||||
) (forge.Forge, forge.RepositoryID, error) {
|
||||
f, repoID, err := resolveRemoteRepositorySilent(ctx, forges, gitRepo, remote)
|
||||
|
||||
var (
|
||||
unsupportedErr *unsupportedForgeError
|
||||
notLoggedInErr *notLoggedInError
|
||||
)
|
||||
switch {
|
||||
case errors.As(err, &unsupportedErr):
|
||||
if unsupportedErr, ok := errors.AsType[*unsupportedForgeError](err); ok {
|
||||
log.Error("Could not guess repository from remote URL", "url", unsupportedErr.RemoteURL)
|
||||
log.Error("Are you sure the remote identifies a supported Git host?")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case errors.As(err, ¬LoggedInErr):
|
||||
return f, repoID, err
|
||||
}
|
||||
|
||||
func openRepository(
|
||||
ctx context.Context,
|
||||
log *silog.Logger,
|
||||
stash secret.Stash,
|
||||
f forge.Forge,
|
||||
repo forge.RepositoryID,
|
||||
) (forge.Repository, error) {
|
||||
forgeRepo, err := openForgeRepository(ctx, stash, f, repo)
|
||||
|
||||
if notLoggedInErr, ok := errors.AsType[*notLoggedInError](err); ok {
|
||||
f := notLoggedInErr.Forge
|
||||
log.Errorf("No authentication token found for %s.", f.ID())
|
||||
log.Errorf("Try running `%s auth login --forge=%s`", cli.Name(), f.ID())
|
||||
return nil, err
|
||||
|
||||
default:
|
||||
return forgeRepo, err
|
||||
}
|
||||
|
||||
return forgeRepo, err
|
||||
}
|
||||
|
||||
+164
-84
@@ -17,9 +17,15 @@ import (
|
||||
"go.abhg.dev/gs/internal/ui"
|
||||
)
|
||||
|
||||
const _forkModeFooter = "Using a different push remote will operate git-spice in Fork mode:\n" +
|
||||
"Local operations will operate normally, but remote operations will be affected.\n" +
|
||||
"In particular, submit will create CRs only for trunk-based branches,\n" +
|
||||
"while still pushing all branches to the push remote."
|
||||
|
||||
type repoInitCmd struct {
|
||||
Trunk string `placeholder:"BRANCH" predictor:"branches" help:"Name of the trunk branch"`
|
||||
Remote string `placeholder:"NAME" predictor:"remotes" help:"Name of the remote to push changes to"`
|
||||
Trunk string `placeholder:"BRANCH" predictor:"branches" help:"Name of the trunk branch"`
|
||||
Remote string `placeholder:"NAME" predictor:"remotes" help:"Name of the remote to push submitted branches to"`
|
||||
Upstream string `placeholder:"NAME" predictor:"remotes" help:"Name of the remote to open change requests against"`
|
||||
|
||||
Reset bool `help:"Forget all information about the repository"`
|
||||
}
|
||||
@@ -32,12 +38,17 @@ func (*repoInitCmd) Help() string {
|
||||
|
||||
Most branch stacking operations are local
|
||||
and do not require a network connection.
|
||||
For operations that push or pull commits, a remote is required.
|
||||
A prompt will ask for one during initialization
|
||||
For operations that push or pull commits, remotes are required.
|
||||
A prompt will ask for them during initialization
|
||||
if not provided with --remote.
|
||||
|
||||
The upstream remote hosts trunk and receives change requests.
|
||||
The push remote receives submitted branch pushes.
|
||||
If only --remote is provided,
|
||||
it is used as both the upstream and push remote.
|
||||
|
||||
Re-run the command on an already initialized repository
|
||||
to change the trunk or remote.
|
||||
to change the trunk or remotes.
|
||||
If the trunk branch is changed on re-initialization,
|
||||
existing branches stacked on the old trunk
|
||||
will be updated to point to the new trunk.
|
||||
@@ -54,7 +65,99 @@ func (cmd *repoInitCmd) Run(
|
||||
repo *git.Repository,
|
||||
wt *git.Worktree,
|
||||
) error {
|
||||
guesser := spice.Guesser{
|
||||
guesser := newRepoGuesser(view)
|
||||
|
||||
remote, err := cmd.resolveRemote(ctx, repo, &guesser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logUsingRemote(log, remote)
|
||||
|
||||
if cmd.Trunk == "" {
|
||||
var err error
|
||||
cmd.Trunk, err = guesser.GuessTrunk(ctx, repo, wt, cmd.Upstream)
|
||||
if err != nil {
|
||||
return fmt.Errorf("guess trunk: %w", err)
|
||||
}
|
||||
} else if !repo.BranchExists(ctx, cmd.Trunk) {
|
||||
// User-provided trunk must be a local branch.
|
||||
log.Errorf("Are you sure %v is a local branch?", cmd.Trunk)
|
||||
return fmt.Errorf("not a branch: %v", cmd.Trunk)
|
||||
}
|
||||
must.NotBeBlankf(cmd.Trunk, "trunk branch must have been set")
|
||||
|
||||
_, err = state.InitStore(ctx, state.InitStoreRequest{
|
||||
DB: newRepoStorage(repo, log),
|
||||
Trunk: cmd.Trunk,
|
||||
Remote: remote,
|
||||
Reset: cmd.Reset,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize storage: %w", err)
|
||||
}
|
||||
|
||||
// If trunk is behind upstream, warn the user.
|
||||
trunkHash, err1 := repo.PeelToCommit(ctx, cmd.Trunk)
|
||||
upstreamHash, err2 := repo.PeelToCommit(ctx, cmd.Upstream+"/"+cmd.Trunk)
|
||||
if err := errors.Join(err1, err2); err == nil {
|
||||
count, err := repo.CountCommits(ctx,
|
||||
git.CommitRangeFrom(upstreamHash).ExcludeFrom(trunkHash))
|
||||
if err == nil && count > 0 {
|
||||
log.Warnf("%v is behind upstream by %d commits", cmd.Trunk, count)
|
||||
log.Warnf("Please run '%s repo sync' before other git-spice commands.", cli.Name())
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Initialized repository", "trunk", cmd.Trunk)
|
||||
return nil
|
||||
}
|
||||
|
||||
// repoInitRemoteGuesser guesses remotes for repository initialization.
|
||||
type repoInitRemoteGuesser interface {
|
||||
GuessUpstreamRemote(context.Context, spice.GitRepository) (string, error)
|
||||
GuessPushRemote(context.Context, spice.GitRepository, string) (string, error)
|
||||
}
|
||||
|
||||
func (cmd *repoInitCmd) resolveRemote(
|
||||
ctx context.Context,
|
||||
repo spice.GitRepository,
|
||||
guesser repoInitRemoteGuesser,
|
||||
) (state.Remote, error) {
|
||||
// If only one of the flags is set,
|
||||
// assume they're both the same remote.
|
||||
upstream := cmp.Or(cmd.Upstream, cmd.Remote)
|
||||
push := cmp.Or(cmd.Remote, upstream)
|
||||
|
||||
// If no remotes were specified on the CLI,
|
||||
// guess or prompt for upstream first.
|
||||
if upstream == "" {
|
||||
var err error
|
||||
upstream, err = guesser.GuessUpstreamRemote(ctx, repo)
|
||||
if err != nil {
|
||||
return state.Remote{}, fmt.Errorf("guess upstream remote: %w", err)
|
||||
}
|
||||
}
|
||||
// Push remote next, defaulting to upstream.
|
||||
if push == "" {
|
||||
var err error
|
||||
push, err = guesser.GuessPushRemote(ctx, repo, upstream)
|
||||
if err != nil {
|
||||
return state.Remote{}, fmt.Errorf("guess push remote: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
remote := state.Remote{
|
||||
Upstream: upstream,
|
||||
Push: push,
|
||||
}
|
||||
cmd.Upstream = remote.Upstream
|
||||
cmd.Remote = remote.Push
|
||||
return remote, nil
|
||||
}
|
||||
|
||||
func newRepoGuesser(view ui.View) spice.Guesser {
|
||||
return spice.Guesser{
|
||||
Select: func(op spice.GuessOp, opts []string, selected string) (string, error) {
|
||||
if !ui.Interactive(view) {
|
||||
return "", errNoPrompt
|
||||
@@ -62,9 +165,12 @@ func (cmd *repoInitCmd) Run(
|
||||
|
||||
var msg, desc string
|
||||
switch op {
|
||||
case spice.GuessRemote:
|
||||
msg = "Please select a remote"
|
||||
desc = "Merged changes will be pushed to this remote"
|
||||
case spice.GuessPushRemote:
|
||||
msg = "Please select a push remote"
|
||||
desc = "Submitted branches will be pushed to this remote"
|
||||
case spice.GuessUpstreamRemote:
|
||||
msg = "Please select an upstream remote"
|
||||
desc = "Change requests will be opened against this remote"
|
||||
case spice.GuessTrunk:
|
||||
msg = "Please select the trunk branch"
|
||||
desc = "Changes will be merged into this branch"
|
||||
@@ -78,6 +184,14 @@ func (cmd *repoInitCmd) Run(
|
||||
With(ui.ComparableOptions(selected, opts...)).
|
||||
WithTitle(msg).
|
||||
WithDescription(desc)
|
||||
if op == spice.GuessPushRemote && selected != "" {
|
||||
prompt.WithFooterFunc(func(remote string) string {
|
||||
if remote == selected {
|
||||
return ""
|
||||
}
|
||||
return _forkModeFooter
|
||||
})
|
||||
}
|
||||
if err := ui.Run(view, prompt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -85,57 +199,6 @@ func (cmd *repoInitCmd) Run(
|
||||
return result, nil
|
||||
},
|
||||
}
|
||||
|
||||
if cmd.Remote == "" {
|
||||
var err error
|
||||
cmd.Remote, err = guesser.GuessRemote(ctx, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("guess remote: %w", err)
|
||||
}
|
||||
if cmd.Remote == "" {
|
||||
log.Warn("No remotes found. Commands that require a remote will fail.")
|
||||
} else {
|
||||
log.Infof("Using remote: %v", cmd.Remote)
|
||||
}
|
||||
}
|
||||
|
||||
if cmd.Trunk == "" {
|
||||
var err error
|
||||
cmd.Trunk, err = guesser.GuessTrunk(ctx, repo, wt, cmd.Remote)
|
||||
if err != nil {
|
||||
return fmt.Errorf("guess trunk: %w", err)
|
||||
}
|
||||
} else if !repo.BranchExists(ctx, cmd.Trunk) {
|
||||
// User-provided trunk must be a local branch.
|
||||
log.Errorf("Are you sure %v is a local branch?", cmd.Trunk)
|
||||
return fmt.Errorf("not a branch: %v", cmd.Trunk)
|
||||
}
|
||||
must.NotBeBlankf(cmd.Trunk, "trunk branch must have been set")
|
||||
|
||||
_, err := state.InitStore(ctx, state.InitStoreRequest{
|
||||
DB: newRepoStorage(repo, log),
|
||||
Trunk: cmd.Trunk,
|
||||
Remote: cmd.Remote,
|
||||
Reset: cmd.Reset,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize storage: %w", err)
|
||||
}
|
||||
|
||||
// If trunk is behind upstream, warn the user.
|
||||
trunkHash, err1 := repo.PeelToCommit(ctx, cmd.Trunk)
|
||||
upstreamHash, err2 := repo.PeelToCommit(ctx, cmd.Remote+"/"+cmd.Trunk)
|
||||
if err := errors.Join(err1, err2); err == nil {
|
||||
count, err := repo.CountCommits(ctx,
|
||||
git.CommitRangeFrom(upstreamHash).ExcludeFrom(trunkHash))
|
||||
if err == nil && count > 0 {
|
||||
log.Warnf("%v is behind upstream by %d commits", cmd.Trunk, count)
|
||||
log.Warnf("Please run '%s repo sync' before other git-spice commands.", cli.Name())
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Initialized repository", "trunk", cmd.Trunk)
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -192,48 +255,65 @@ func ensureRemote(
|
||||
store *state.Store,
|
||||
log *silog.Logger,
|
||||
view ui.View,
|
||||
) (string, error) {
|
||||
) (state.Remote, error) {
|
||||
remote, err := store.Remote()
|
||||
if err == nil {
|
||||
return remote, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, state.ErrNotExist) {
|
||||
return "", fmt.Errorf("get remote: %w", err)
|
||||
return state.Remote{}, fmt.Errorf("get remote: %w", err)
|
||||
}
|
||||
|
||||
// No remote was specified at init time.
|
||||
// Guess or prompt for one and update the store.
|
||||
// Guess or prompt for remotes and update the store.
|
||||
log.Warn("No remote was specified at init time")
|
||||
remote, err = (&spice.Guesser{
|
||||
Select: func(_ spice.GuessOp, opts []string, selected string) (string, error) {
|
||||
if !ui.Interactive(view) {
|
||||
return "", errNoPrompt
|
||||
}
|
||||
guesser := newRepoGuesser(view)
|
||||
|
||||
result := selected
|
||||
prompt := ui.NewSelect[string]().
|
||||
WithValue(&result).
|
||||
With(ui.ComparableOptions(selected, opts...)).
|
||||
WithTitle("Please select a remote").
|
||||
WithDescription("Changes will be pushed to this remote")
|
||||
if err := ui.Run(view, prompt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
},
|
||||
}).GuessRemote(ctx, repo)
|
||||
upstream, err := guesser.GuessUpstreamRemote(ctx, repo)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("guess remote: %w", err)
|
||||
return state.Remote{}, fmt.Errorf("guess upstream remote: %w", err)
|
||||
}
|
||||
|
||||
remote = state.Remote{
|
||||
Upstream: upstream,
|
||||
}
|
||||
remote.Push, err = guesser.GuessPushRemote(ctx, repo, upstream)
|
||||
if err != nil {
|
||||
return state.Remote{}, fmt.Errorf("guess push remote: %w", err)
|
||||
}
|
||||
|
||||
if err := store.SetRemote(ctx, remote); err != nil {
|
||||
return "", fmt.Errorf("set remote: %w", err)
|
||||
return state.Remote{}, fmt.Errorf("set remote: %w", err)
|
||||
}
|
||||
|
||||
// TODO: this should also update the Forge associated with the spice.Service.
|
||||
|
||||
log.Infof("Changed repository remote to %s", remote)
|
||||
logChangedRemote(log, remote)
|
||||
return remote, nil
|
||||
}
|
||||
|
||||
func logUsingRemote(log *silog.Logger, remote state.Remote) {
|
||||
if remote == (state.Remote{}) {
|
||||
log.Warn("No remotes found. Commands that require a remote will fail.")
|
||||
return
|
||||
}
|
||||
if remote.ForkMode() {
|
||||
log.Infof("Using upstream remote: %s", remote.Upstream)
|
||||
log.Infof("Using push remote: %s", remote.Push)
|
||||
return
|
||||
}
|
||||
log.Infof("Using remote: %s", cmp.Or(remote.Upstream, remote.Push))
|
||||
}
|
||||
|
||||
func logChangedRemote(log *silog.Logger, remote state.Remote) {
|
||||
if remote == (state.Remote{}) {
|
||||
return
|
||||
}
|
||||
if remote.ForkMode() {
|
||||
log.Infof("Changed repository upstream remote to %s", remote.Upstream)
|
||||
log.Infof("Changed repository push remote to %s", remote.Push)
|
||||
return
|
||||
}
|
||||
log.Infof("Changed repository remote to %s", cmp.Or(remote.Upstream, remote.Push))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.abhg.dev/gs/internal/spice"
|
||||
"go.abhg.dev/gs/internal/spice/state"
|
||||
)
|
||||
|
||||
func TestRepoInitCmd_resolveRemote(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
upstream string
|
||||
push string
|
||||
|
||||
guesser fakeRepoInitRemoteGuesser
|
||||
|
||||
want state.Remote
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "NoFlags",
|
||||
guesser: fakeRepoInitRemoteGuesser{
|
||||
upstreams: []remoteGuessResult{{value: "upstream"}},
|
||||
pushes: []remoteGuessResult{{value: "origin"}},
|
||||
},
|
||||
want: state.Remote{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RemoteOnly",
|
||||
push: "origin",
|
||||
want: state.Remote{
|
||||
Upstream: "origin",
|
||||
Push: "origin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "UpstreamOnly",
|
||||
upstream: "upstream",
|
||||
want: state.Remote{
|
||||
Upstream: "upstream",
|
||||
Push: "upstream",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ForkMode",
|
||||
upstream: "upstream",
|
||||
push: "origin",
|
||||
want: state.Remote{
|
||||
Upstream: "upstream",
|
||||
Push: "origin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "UpstreamGuessError",
|
||||
guesser: fakeRepoInitRemoteGuesser{
|
||||
upstreams: []remoteGuessResult{{err: errors.New("no upstream")}},
|
||||
},
|
||||
wantErr: "guess upstream remote: no upstream",
|
||||
},
|
||||
{
|
||||
name: "PushGuessError",
|
||||
guesser: fakeRepoInitRemoteGuesser{
|
||||
upstreams: []remoteGuessResult{{value: "upstream"}},
|
||||
pushes: []remoteGuessResult{{err: errors.New("no push")}},
|
||||
},
|
||||
wantErr: "guess push remote: no push",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &repoInitCmd{
|
||||
Upstream: tt.upstream,
|
||||
Remote: tt.push,
|
||||
}
|
||||
guesser := tt.guesser
|
||||
got, err := cmd.resolveRemote(
|
||||
t.Context(),
|
||||
nil,
|
||||
&guesser,
|
||||
)
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.EqualError(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
assert.Equal(t, tt.want.Upstream, cmd.Upstream)
|
||||
assert.Equal(t, tt.want.Push, cmd.Remote)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type remoteGuessResult struct {
|
||||
value string
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeRepoInitRemoteGuesser struct {
|
||||
upstreams []remoteGuessResult
|
||||
pushes []remoteGuessResult
|
||||
}
|
||||
|
||||
func (g *fakeRepoInitRemoteGuesser) GuessUpstreamRemote(
|
||||
context.Context,
|
||||
spice.GitRepository,
|
||||
) (string, error) {
|
||||
return g.nextUpstream()
|
||||
}
|
||||
|
||||
func (g *fakeRepoInitRemoteGuesser) GuessPushRemote(
|
||||
_ context.Context,
|
||||
_ spice.GitRepository,
|
||||
_ string,
|
||||
) (string, error) {
|
||||
return g.nextPush()
|
||||
}
|
||||
|
||||
func (g *fakeRepoInitRemoteGuesser) nextUpstream() (string, error) {
|
||||
if len(g.upstreams) == 0 {
|
||||
return "", errors.New("unexpected upstream guess")
|
||||
}
|
||||
next := g.upstreams[0]
|
||||
g.upstreams = g.upstreams[1:]
|
||||
return next.value, next.err
|
||||
}
|
||||
|
||||
func (g *fakeRepoInitRemoteGuesser) nextPush() (string, error) {
|
||||
if len(g.pushes) == 0 {
|
||||
return "", errors.New("unexpected push guess")
|
||||
}
|
||||
next := g.pushes[0]
|
||||
g.pushes = g.pushes[1:]
|
||||
return next.value, next.err
|
||||
}
|
||||
Vendored
+11
-6
@@ -6,19 +6,24 @@ A trunk branch is required. This is the branch that changes will be merged into.
|
||||
A prompt will ask for one if not provided with --trunk.
|
||||
|
||||
Most branch stacking operations are local and do not require a network
|
||||
connection. For operations that push or pull commits, a remote is required.
|
||||
A prompt will ask for one during initialization if not provided with --remote.
|
||||
connection. For operations that push or pull commits, remotes are required.
|
||||
A prompt will ask for them during initialization if not provided with --remote.
|
||||
|
||||
The upstream remote hosts trunk and receives change requests. The push remote
|
||||
receives submitted branch pushes. If only --remote is provided, it is used as
|
||||
both the upstream and push remote.
|
||||
|
||||
Re-run the command on an already initialized repository to change the trunk or
|
||||
remote. If the trunk branch is changed on re-initialization, existing branches
|
||||
remotes. If the trunk branch is changed on re-initialization, existing branches
|
||||
stacked on the old trunk will be updated to point to the new trunk.
|
||||
|
||||
Re-run with --reset to discard all stored information and untrack all branches.
|
||||
|
||||
Flags:
|
||||
--trunk=BRANCH Name of the trunk branch
|
||||
--remote=NAME Name of the remote to push changes to
|
||||
--reset Forget all information about the repository
|
||||
--trunk=BRANCH Name of the trunk branch
|
||||
--remote=NAME Name of the remote to push submitted branches to
|
||||
--upstream=NAME Name of the remote to open change requests against
|
||||
--reset Forget all information about the repository
|
||||
|
||||
Global Flags:
|
||||
-h, --help Show help for the command
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# 'gs branch checkout' recovers remote branches from the push remote
|
||||
# in fork mode.
|
||||
|
||||
as 'Test User <test@example.com>'
|
||||
at '2026-05-03T10:12:00Z'
|
||||
|
||||
# Create an upstream repository without the feature branch.
|
||||
mkdir upstream
|
||||
cd upstream
|
||||
git init
|
||||
git commit --allow-empty -m 'Initial commit'
|
||||
|
||||
# Create a fork that has the feature branch.
|
||||
cd ..
|
||||
git clone upstream fork
|
||||
cd fork
|
||||
git checkout -b feature
|
||||
cp $WORK/extra/feature.txt feature.txt
|
||||
git add feature.txt
|
||||
git commit -m 'Add feature'
|
||||
|
||||
# Clone the upstream repository and add the fork as the push remote.
|
||||
cd ..
|
||||
git clone upstream repo
|
||||
cd repo
|
||||
git remote rename origin upstream
|
||||
git remote add origin $WORK/fork
|
||||
git fetch origin
|
||||
gs repo init --upstream upstream --remote origin
|
||||
|
||||
# The branch exists only on the push remote.
|
||||
! git rev-parse --verify --quiet refs/heads/feature
|
||||
! git rev-parse --verify --quiet refs/remotes/upstream/feature
|
||||
git rev-parse --verify --quiet refs/remotes/origin/feature
|
||||
|
||||
env ROBOT_INPUT=$WORK/robot.golden ROBOT_OUTPUT=$WORK/robot.actual
|
||||
|
||||
gs branch checkout feature
|
||||
|
||||
git rev-parse --abbrev-ref @{upstream}
|
||||
stdout 'origin/feature'
|
||||
|
||||
cmp $WORK/robot.actual $WORK/robot.golden
|
||||
|
||||
-- extra/feature.txt --
|
||||
feature
|
||||
-- robot.golden --
|
||||
===
|
||||
> Do you want to track this branch now?: [Y/n]
|
||||
true
|
||||
@@ -0,0 +1,90 @@
|
||||
# Test fork workflow: submit to upstream,
|
||||
# then sync after the upstream change is merged.
|
||||
|
||||
as 'Test <test@example.com>'
|
||||
at '2026-04-26T17:20:00Z'
|
||||
|
||||
# Alice creates the upstream repository.
|
||||
mkdir -p alice/store
|
||||
cd alice/store
|
||||
git init
|
||||
git commit --allow-empty -m 'Initial commit'
|
||||
|
||||
shamhub init
|
||||
shamhub register alice
|
||||
shamhub register bob
|
||||
shamhub new upstream alice/store
|
||||
git push upstream main
|
||||
|
||||
cp $WORK/extra/initial.txt store.txt
|
||||
git add store.txt
|
||||
git commit -m 'Add initial store content'
|
||||
git push upstream main
|
||||
|
||||
# Bob forks the repository and adds the upstream remote.
|
||||
shamhub fork alice/store bob
|
||||
mkdir $WORK/bob
|
||||
shamhub clone bob/store $WORK/bob/store
|
||||
cd $WORK/bob/store
|
||||
git remote add upstream $SHAMHUB_URL/alice/store
|
||||
git fetch upstream
|
||||
|
||||
git remote -v
|
||||
stdout 'origin.*bob/store'
|
||||
stdout 'upstream.*alice/store'
|
||||
|
||||
gs repo init --remote=origin --upstream=upstream
|
||||
env SHAMHUB_USERNAME=bob
|
||||
gs auth login
|
||||
|
||||
cp $WORK/extra/feature.txt feature.txt
|
||||
git add feature.txt
|
||||
gs branch create feature -m 'Add feature from fork'
|
||||
|
||||
gs branch submit --fill
|
||||
stderr 'Created #1:'
|
||||
|
||||
shamhub dump change 1
|
||||
cmpenvJSON stdout $WORK/golden/pr-created.json
|
||||
|
||||
git ls-remote origin feature
|
||||
stdout 'refs/heads/feature'
|
||||
|
||||
shamhub merge alice/store 1
|
||||
|
||||
gs repo sync
|
||||
stderr 'pulled 2 new commit'
|
||||
stderr '#1 was merged'
|
||||
|
||||
git checkout main
|
||||
cmp feature.txt $WORK/extra/feature.txt
|
||||
! git show-ref refs/heads/feature
|
||||
|
||||
-- extra/initial.txt --
|
||||
Initial store content
|
||||
-- extra/feature.txt --
|
||||
Feature from Bob's fork
|
||||
-- golden/pr-created.json --
|
||||
{
|
||||
"number": 1,
|
||||
"state": "open",
|
||||
"title": "Add feature from fork",
|
||||
"body": "",
|
||||
"html_url": "$SHAMHUB_URL/alice/store/change/1",
|
||||
"head": {
|
||||
"repository": {
|
||||
"owner": "bob",
|
||||
"name": "store"
|
||||
},
|
||||
"ref": "feature",
|
||||
"sha": "43125a4fcc8869f32593d01247bd2f1f0afb2902"
|
||||
},
|
||||
"base": {
|
||||
"repository": {
|
||||
"owner": "alice",
|
||||
"name": "store"
|
||||
},
|
||||
"ref": "main",
|
||||
"sha": "7572890b516c26623e5f02668c5837fa853d3d79"
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Test fork workflow:
|
||||
# branch submit pushes stacked branches without creating Change Requests.
|
||||
|
||||
as 'Test <test@example.com>'
|
||||
at '2026-05-02T18:20:00Z'
|
||||
|
||||
# Alice creates the upstream repository.
|
||||
mkdir -p alice/store
|
||||
cd alice/store
|
||||
git init
|
||||
git commit --allow-empty -m 'Initial commit'
|
||||
|
||||
shamhub init
|
||||
shamhub register alice
|
||||
shamhub register bob
|
||||
shamhub new upstream alice/store
|
||||
git push upstream main
|
||||
|
||||
cp $WORK/extra/base.txt readme.txt
|
||||
git add readme.txt
|
||||
git commit -m 'Add readme'
|
||||
git push upstream main
|
||||
|
||||
# Bob forks the repository and adds the upstream remote.
|
||||
shamhub fork alice/store bob
|
||||
mkdir $WORK/bob
|
||||
shamhub clone bob/store $WORK/bob/store
|
||||
cd $WORK/bob/store
|
||||
git remote add upstream $SHAMHUB_URL/alice/store
|
||||
|
||||
gs repo init --remote=origin --upstream=upstream
|
||||
env SHAMHUB_USERNAME=bob
|
||||
gs auth login
|
||||
|
||||
# Bob creates a stacked branch.
|
||||
cp $WORK/extra/feat1.txt feat1.txt
|
||||
git add feat1.txt
|
||||
gs branch create feat1 -m 'Add feature 1'
|
||||
|
||||
cp $WORK/extra/feat2.txt feat2.txt
|
||||
git add feat2.txt
|
||||
gs branch create feat2 -m 'Add feature 2'
|
||||
|
||||
gs branch submit --fill
|
||||
stderr 'feat2: Pushing to origin, skipping CR: base is feat1'
|
||||
stderr 'Pushed feat2'
|
||||
|
||||
git ls-remote origin feat2
|
||||
stdout 'refs/heads/feat2'
|
||||
|
||||
shamhub dump changes
|
||||
cmpenvJSON stdout $WORK/golden/no-prs.json
|
||||
|
||||
-- extra/base.txt --
|
||||
Base repository content
|
||||
-- extra/feat1.txt --
|
||||
Feature 1 implementation
|
||||
-- extra/feat2.txt --
|
||||
Feature 2 implementation
|
||||
-- golden/no-prs.json --
|
||||
[]
|
||||
+17
-9
@@ -1,5 +1,5 @@
|
||||
# 'branch submit' after initializing without a remote
|
||||
# will ask for a remote to push to.
|
||||
# will ask for upstream and push remotes.
|
||||
|
||||
as 'Test <test@example.com>'
|
||||
at '2024-04-05T16:40:32Z'
|
||||
@@ -13,9 +13,9 @@ gs repo init
|
||||
shamhub init
|
||||
shamhub new upstream alice/example.git
|
||||
shamhub register bob
|
||||
shamhub new origin bob/example-fork.git
|
||||
git push upstream main
|
||||
git push origin main
|
||||
shamhub fork alice/example bob
|
||||
git remote add origin $SHAMHUB_URL/bob/example
|
||||
|
||||
git add feature1.txt
|
||||
gs bc -m 'Add feature1' feature1
|
||||
@@ -35,12 +35,20 @@ Contents of feature1
|
||||
|
||||
-- robot.golden --
|
||||
===
|
||||
> Please select a remote:
|
||||
> Please select an upstream remote:
|
||||
>
|
||||
> ▶ origin
|
||||
> upstream
|
||||
>
|
||||
> Changes will be pushed to this remote
|
||||
> Change requests will be opened against this remote
|
||||
"upstream"
|
||||
===
|
||||
> Please select a push remote:
|
||||
>
|
||||
> origin
|
||||
> ▶ upstream
|
||||
>
|
||||
> Submitted branches will be pushed to this remote
|
||||
"origin"
|
||||
|
||||
-- golden/pulls.json --
|
||||
@@ -50,19 +58,19 @@ Contents of feature1
|
||||
"state": "open",
|
||||
"title": "Add feature1",
|
||||
"body": "",
|
||||
"html_url": "$SHAMHUB_URL/bob/example-fork/change/1",
|
||||
"html_url": "$SHAMHUB_URL/alice/example/change/1",
|
||||
"head": {
|
||||
"repository": {
|
||||
"owner": "bob",
|
||||
"name": "example-fork"
|
||||
"name": "example"
|
||||
},
|
||||
"ref": "feature1",
|
||||
"sha": "25596d0f65bb595aa658f9fa7fe164949cd2ea0b"
|
||||
},
|
||||
"base": {
|
||||
"repository": {
|
||||
"owner": "bob",
|
||||
"name": "example-fork"
|
||||
"owner": "alice",
|
||||
"name": "example"
|
||||
},
|
||||
"ref": "main",
|
||||
"sha": "ece8ed7bb81d74cb6787309fa41b7deb2e0558a3"
|
||||
|
||||
+11
-3
@@ -1,4 +1,4 @@
|
||||
# 'gs repo init' prompts for a remote
|
||||
# 'gs repo init' prompts for upstream and push remotes
|
||||
# if there are multiple remotes.
|
||||
|
||||
as 'Test <test@example.com>'
|
||||
@@ -18,10 +18,18 @@ cmp $WORK/robot.actual $WORK/robot.golden
|
||||
|
||||
-- robot.golden --
|
||||
===
|
||||
> Please select a remote:
|
||||
> Please select an upstream remote:
|
||||
>
|
||||
> ▶ origin
|
||||
> upstream
|
||||
>
|
||||
> Merged changes will be pushed to this remote
|
||||
> Change requests will be opened against this remote
|
||||
"upstream"
|
||||
===
|
||||
> Please select a push remote:
|
||||
>
|
||||
> origin
|
||||
> ▶ upstream
|
||||
>
|
||||
> Submitted branches will be pushed to this remote
|
||||
"origin"
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Test fork workflow:
|
||||
# stack submit only submits trunk-based branches.
|
||||
|
||||
as 'Test <test@example.com>'
|
||||
at '2026-04-26T17:25:00Z'
|
||||
|
||||
# Alice creates the upstream repository.
|
||||
mkdir -p alice/store
|
||||
cd alice/store
|
||||
git init
|
||||
git commit --allow-empty -m 'Initial commit'
|
||||
|
||||
shamhub init
|
||||
shamhub register alice
|
||||
shamhub register bob
|
||||
shamhub new upstream alice/store
|
||||
git push upstream main
|
||||
|
||||
cp $WORK/extra/base.txt readme.txt
|
||||
git add readme.txt
|
||||
git commit -m 'Add readme'
|
||||
git push upstream main
|
||||
|
||||
# Bob forks the repository and adds the upstream remote.
|
||||
shamhub fork alice/store bob
|
||||
mkdir $WORK/bob
|
||||
shamhub clone bob/store $WORK/bob/store
|
||||
cd $WORK/bob/store
|
||||
git remote add upstream $SHAMHUB_URL/alice/store
|
||||
|
||||
gs repo init --remote=origin --upstream=upstream
|
||||
env SHAMHUB_USERNAME=bob
|
||||
gs auth login
|
||||
|
||||
# Bob creates one stacked branch and one trunk-based branch.
|
||||
cp $WORK/extra/feat1.txt feat1.txt
|
||||
git add feat1.txt
|
||||
gs branch create feat1 -m 'Add feature 1'
|
||||
|
||||
cp $WORK/extra/feat2.txt feat2.txt
|
||||
git add feat2.txt
|
||||
gs branch create feat2 -m 'Add feature 2'
|
||||
|
||||
gs trunk
|
||||
cp $WORK/extra/feat3.txt feat3.txt
|
||||
git add feat3.txt
|
||||
gs branch create feat3 -m 'Add feature 3'
|
||||
|
||||
git checkout feat1
|
||||
gs stack submit --fill
|
||||
stderr 'Created #1'
|
||||
stderr 'feat2: Pushing to origin, skipping CR: base is feat1'
|
||||
git ls-remote origin feat2
|
||||
stdout 'refs/heads/feat2'
|
||||
|
||||
git checkout feat3
|
||||
gs stack submit --fill
|
||||
stderr 'Created #2'
|
||||
|
||||
shamhub dump changes
|
||||
cmpenvJSON stdout $WORK/golden/stack-prs.json
|
||||
|
||||
-- extra/base.txt --
|
||||
Base repository content
|
||||
-- extra/feat1.txt --
|
||||
Feature 1 implementation
|
||||
-- extra/feat2.txt --
|
||||
Feature 2 implementation
|
||||
-- extra/feat3.txt --
|
||||
Feature 3 implementation
|
||||
-- golden/stack-prs.json --
|
||||
[
|
||||
{
|
||||
"number": 1,
|
||||
"state": "open",
|
||||
"title": "Add feature 1",
|
||||
"body": "",
|
||||
"html_url": "$SHAMHUB_URL/alice/store/change/1",
|
||||
"head": {
|
||||
"repository": {
|
||||
"owner": "bob",
|
||||
"name": "store"
|
||||
},
|
||||
"ref": "feat1",
|
||||
"sha": "56e3663a540638a1a3a3900d8d528a1f12c5d4c2"
|
||||
},
|
||||
"base": {
|
||||
"repository": {
|
||||
"owner": "alice",
|
||||
"name": "store"
|
||||
},
|
||||
"ref": "main",
|
||||
"sha": "592676da01d33edc2b95206ccccc643aeaae1881"
|
||||
}
|
||||
},
|
||||
{
|
||||
"number": 2,
|
||||
"state": "open",
|
||||
"title": "Add feature 3",
|
||||
"body": "",
|
||||
"html_url": "$SHAMHUB_URL/alice/store/change/2",
|
||||
"head": {
|
||||
"repository": {
|
||||
"owner": "bob",
|
||||
"name": "store"
|
||||
},
|
||||
"ref": "feat3",
|
||||
"sha": "4aaf279868ff97c6eb239ec11919247c348e3f53"
|
||||
},
|
||||
"base": {
|
||||
"repository": {
|
||||
"owner": "alice",
|
||||
"name": "store"
|
||||
},
|
||||
"ref": "main",
|
||||
"sha": "592676da01d33edc2b95206ccccc643aeaae1881"
|
||||
}
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user