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

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:
Abhinav Gupta
2026-05-03 10:48:09 -07:00
committed by GitHub
parent 78acbbc0e2
commit c44e8f9ab8
40 changed files with 1729 additions and 317 deletions
@@ -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
+2 -3
View File
@@ -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
}
}
+10 -4
View File
@@ -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}
+29
View File
@@ -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:
+53 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+9
View File
@@ -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`.
+8 -8
View File
@@ -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
+5 -5
View File
@@ -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").
+6 -5
View File
@@ -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
}
+17 -11
View File
@@ -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,
}
}
}
}
+3 -3
View File
@@ -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())
+135 -49
View File
@@ -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
+68
View File
@@ -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)
}
+5 -1
View File
@@ -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)
+4 -1
View File
@@ -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
}
+26 -5
View File
@@ -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
View File
@@ -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)
}
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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
}
+100 -2
View File
@@ -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",
},
},
},
}
+7 -4
View File
@@ -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)
+21 -14
View File
@@ -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
}
+239
View File
@@ -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{
+5 -10
View File
@@ -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:
+16 -32
View File
@@ -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,
+17 -5
View File
@@ -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
}),
)
+54 -19
View File
@@ -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, &notLoggedInErr):
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
View File
@@ -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))
}
+146
View File
@@ -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
}
+11 -6
View File
@@ -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
View File
@@ -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
+90
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
]