diff --git a/README.md b/README.md index 35b8494..4e63dc4 100644 --- a/README.md +++ b/README.md @@ -323,7 +323,8 @@ Performs a safe, non-interactive synchronization of the entire stack: 3. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state and you are advised to run `gh stack rebase` to resolve conflicts interactively 4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred) 5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR -6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically +6. **Sync the stack** — links the stack's open PRs into a stack on GitHub, creating the remote stack object if it doesn't exist yet or updating it if it's partially formed. Only happens when two or more PRs exist; sync never opens PRs (use `gh stack submit` for that) +7. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically | Flag | Description | |------|-------------| diff --git a/cmd/submit.go b/cmd/submit.go index 7daf067..a1ba004 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -690,7 +690,11 @@ func clearPendingModifyState(cfg *config.Config, gitDir string) { // yet, it calls POST to create one. // This is a best-effort operation: failures are reported as warnings but do // not cause the submit command to fail (the PRs are already created). -func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) { +// +// It returns true when the remote stack object reflects the local stack +// (created, updated, or already in sync) and false otherwise (fewer than two +// PRs, an unresolved divergence, stacked PRs unavailable, or an API failure). +func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) bool { // Collect PR numbers in stack order (bottom to top), including merged PRs. // The API expects the full list — omitting merged PRs causes a // "Stack contents have changed" rejection. @@ -703,41 +707,34 @@ func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) { // The API requires at least 2 PRs to form a stack. if len(prNumbers) < 2 { - return + return false } if s.ID != "" { - updateStack(cfg, client, s, prNumbers) - return + return updateStack(cfg, client, s, prNumbers) } // No locally tracked stack ID. The stack may already exist on GitHub // (created from the web UI or another clone) without being recorded // locally. Adopt it instead of blindly creating a new one, which the API // rejects because the PRs are already part of a stack. - if adoptRemoteStack(cfg, client, s, prNumbers) { - return - } - - createNewStack(cfg, client, s, prNumbers) + return reconcileUntrackedStack(cfg, client, s, prNumbers) } -// adoptRemoteStack reconciles a locally untracked stack (s.ID == "") with the -// stacks that already exist on GitHub. The PRs in s may already belong to a -// remote stack created from the web UI or another clone; in that case we must -// adopt that stack rather than POST a new one (which the API rejects because -// the PRs are already stacked). -// -// It returns true when it has fully handled the sync — either by adopting and -// updating the existing stack, or by intentionally refusing to modify a -// divergent remote stack — and false when no matching remote stack exists and -// the caller should create a new one. -func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool { +// reconcileUntrackedStack reconciles a locally untracked stack (s.ID == "") +// with the stacks that already exist on GitHub. The PRs in s may already belong +// to a remote stack created from the web UI or another clone; in that case we +// adopt that stack rather than POST a new one (which the API rejects because the +// PRs are already stacked). It creates a new stack when none match, refuses to +// modify a divergent or PR-dropping stack, adopts a matching stack, or updates a +// partially-formed one. It returns true when the remote stack object now +// reflects the local stack. +func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool { stacks, err := client.ListStacks() if err != nil { // Couldn't inspect remote state — fall back to the create path, which // reports its own errors (handleCreate422 covers "already stacked"). - return false + return createNewStack(cfg, client, s, prNumbers) } matched, err := findMatchingStack(stacks, prNumbers) @@ -745,15 +742,15 @@ func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stac // Our PRs are spread across more than one remote stack. A PR can only // belong to one stack, so this is a genuine divergence we can't resolve // automatically. - cfg.Warningf("Your PRs belong to multiple stacks on GitHub — reconcile them before submitting") + cfg.Warningf("Your PRs belong to multiple stacks on GitHub — reconcile them first") cfg.Printf(" Run `%s` to import a stack, or unstack the PRs from the web", cfg.ColorCyan("gh stack checkout ")) - return true + return false } if matched == nil { // No existing stack contains any of our PRs — create a new one. - return false + return createNewStack(cfg, client, s, prNumbers) } // A remote stack already contains some of our PRs. Refuse to silently drop @@ -761,9 +758,9 @@ func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stac if dropped := prsMissingFrom(matched.PullRequests, prNumbers); len(dropped) > 0 { cfg.Warningf("A stack on GitHub already contains %s, which %s not in your local stack", formatPRList(dropped), plural(len(dropped), "is", "are")) - cfg.Printf(" Run `%s` to import the full stack, then `%s`", - cfg.ColorCyan("gh stack checkout "), cfg.ColorCyan("gh stack submit")) - return true + cfg.Printf(" Run `%s` to import the full stack", + cfg.ColorCyan("gh stack checkout ")) + return false } // Every PR in the remote stack is tracked locally (and we may have added @@ -777,8 +774,7 @@ func adoptRemoteStack(cfg *config.Config, client github.ClientOps, s *stack.Stac } cfg.Infof("Found the stack on GitHub — updating it to match your local stack") - updateStack(cfg, client, s, prNumbers) - return true + return updateStack(cfg, client, s, prNumbers) } // prsMissingFrom returns the numbers in remote that do not appear in local, @@ -800,7 +796,8 @@ func prsMissingFrom(remote, local []int) []int { // updateStack calls the PUT endpoint to sync the full PR list for an existing stack. // If the remote stack was deleted (404), it clears the local ID and falls through // to createNewStack so the user doesn't need to re-run the command. -func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) { +// Returns true when the remote stack was updated (or recreated) successfully. +func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool { if err := client.UpdateStack(s.ID, prNumbers); err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) { @@ -809,7 +806,7 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr // Stack was deleted on GitHub — clear the stale ID and // immediately try to re-create it. s.ID = "" - createNewStack(cfg, client, s, prNumbers) + return createNewStack(cfg, client, s, prNumbers) case 422: // A merged branch whose ref has been deleted upstream breaks the // stack's base→head chain, so the update is rejected. This is @@ -818,7 +815,7 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr // than alarming the user with a raw API error. if strings.Contains(httpErr.Message, "must form a stack") && len(s.MergedBranches()) > 0 { cfg.Infof("Merged PRs have left the stack on GitHub, so it wasn't updated — your unmerged PRs were pushed and re-based onto the trunk") - return + return false } cfg.Warningf("Failed to update stack on GitHub: %s", httpErr.Message) default: @@ -827,34 +824,38 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr } else { cfg.Warningf("Failed to update stack on GitHub: %v", err) } - return + return false } cfg.Successf("Stack updated on GitHub with %d PRs", len(prNumbers)) + return true } // createNewStack calls the POST endpoint to create a new stack, handling the // three types of 422 errors the API may return. -func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) { +// Returns true when the stack was created or is confirmed already in sync. +func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool { stackID, err := client.CreateStack(prNumbers) if err == nil { s.ID = strconv.Itoa(stackID) cfg.Successf("Stack created on GitHub with %d PRs", len(prNumbers)) - return + return true } var httpErr *api.HTTPError if !errors.As(err, &httpErr) { cfg.Warningf("Failed to create stack on GitHub: %v", err) - return + return false } switch httpErr.StatusCode { case 422: - handleCreate422(cfg, httpErr, prNumbers) + return handleCreate422(cfg, httpErr, prNumbers) case 404: warnStacksUnavailableOrPAT(cfg) + return false default: cfg.Warningf("Failed to create stack on GitHub: %s", httpErr.Message) + return false } } @@ -863,7 +864,10 @@ func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, // - "Stack must contain at least two pull requests" // - "Pull requests must form a stack, where each PR's base ref is the previous PR's head ref" // - "Pull requests #123, #124, #125 are already stacked" -func handleCreate422(cfg *config.Config, httpErr *api.HTTPError, prNumbers []int) { +// +// Returns true only when the PRs are already stacked together (i.e. the remote +// stack already matches), which counts as in sync. +func handleCreate422(cfg *config.Config, httpErr *api.HTTPError, prNumbers []int) bool { msg := httpErr.Message if isAlreadyStackedError(msg) { @@ -872,22 +876,23 @@ func handleCreate422(cfg *config.Config, httpErr *api.HTTPError, prNumbers []int // If only a subset matches, the PRs are in a different stack. if allPRsInMessage(msg, prNumbers) { cfg.Successf("Stack with %d PRs is up to date", len(prNumbers)) - return + return true } cfg.Warningf("One or more PRs are already part of a different stack on GitHub") cfg.Printf(" Run `%s` to import the existing stack, or unstack the PRs from the web", cfg.ColorCyan("gh stack checkout ")) - return + return false } if strings.Contains(msg, "must form a stack") { cfg.Warningf("Cannot create stack: %s", msg) cfg.Printf(" Each PR's base branch must match the previous PR's head branch.") - return + return false } // "at least two" or any other validation error cfg.Warningf("Could not create stack: %s", msg) + return false } // allPRsInMessage checks whether every PR number in prNumbers appears diff --git a/cmd/sync.go b/cmd/sync.go index d09f550..70bd831 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -33,11 +33,20 @@ This command performs a safe, non-interactive synchronization: 3. Cascade-rebases stack branches onto their updated parents 4. Pushes all branches atomically (using --force-with-lease --atomic) 5. Syncs PR state from GitHub + 6. Links the stack's open PRs into a stack on GitHub (creating or updating + the remote stack object) when two or more PRs exist If a rebase conflict is detected, all branches are restored to their original state and you are advised to run "gh stack rebase" to resolve conflicts interactively. +Sync never opens pull requests — use "gh stack submit" for that. It only +links PRs that already exist. The final message reflects what happened: +"Stack synced" means the stack object on GitHub now matches your local +stack, while "Branches synced" means the branches were rebased and pushed +but no remote stack object was created or updated (for example, when fewer +than two PRs exist yet). + Use --prune to delete local branches for merged PRs. Stack metadata is preserved so that rebase and display logic continue to work correctly. If you are on a branch that would be pruned, your checkout is moved to @@ -220,6 +229,18 @@ func runSync(cfg *config.Config, opts *syncOptions) error { cfg.Printf("Merged: %s", strings.Join(names, ", ")) } + // --- Step 5b: Reconcile the remote stack object --- + // syncStackPRs above only refreshes local PR associations; it does not touch + // the stack object on GitHub. When the branches have open PRs, link them into + // a stack so the remote reflects the local stack. This never opens PRs — that + // is still `gh stack submit`'s job. stackSynced records whether the remote + // stack object actually reflects the local stack, which determines the final + // summary message below. + stackSynced := false + if client, err := cfg.GitHubClient(); err == nil { + stackSynced = syncStack(cfg, client, s) + } + // --- Step 6: Prune merged branches (optional) --- doPrune := opts.prune if !doPrune { @@ -316,7 +337,14 @@ func runSync(cfg *config.Config, opts *syncOptions) error { } cfg.Printf("") - cfg.Successf("Stack synced") + if stackSynced { + cfg.Successf("Stack synced") + } else { + // The branches were fetched, rebased, and pushed, but no stack object on + // GitHub was created or updated (no PRs, fewer than two PRs, stacked PRs + // unavailable, or a divergence). Report only what actually happened. + cfg.Successf("Branches synced") + } return nil } diff --git a/cmd/sync_test.go b/cmd/sync_test.go index be43f6b..746739e 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -6,8 +6,10 @@ import ( "strings" "testing" + "github.com/cli/go-gh/v2/pkg/api" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1603,3 +1605,270 @@ func TestSync_ExplicitPrune_SkipsPrompt(t *testing.T) { assert.NoError(t, err) assert.Equal(t, []string{"b1"}, deletedBranches) } + +// --- Remote stack object reconciliation ------------------------------------- + +// newSyncMockNoRebase returns a sync git mock whose trunk is already up to date, +// so no fast-forward or cascade rebase occurs and the run reaches the remote +// stack reconciliation step cleanly. +func newSyncMockNoRebase(tmpDir, currentBranch string) *git.MockOps { + m := newSyncMock(tmpDir, currentBranch) + m.RevParseFn = func(ref string) (string, error) { + if ref == "main" || ref == "origin/main" { + return "trunk-sha", nil + } + if strings.HasPrefix(ref, "origin/") { + return "sha-" + strings.TrimPrefix(ref, "origin/"), nil + } + return "sha-" + ref, nil + } + return m +} + +// openPRFinder returns a FindPRForBranch func that reports OPEN PRs for the +// branches in prFor (branch name -> PR number) and nil for any other branch. +func openPRFinder(prFor map[string]int) func(string) (*github.PullRequest, error) { + return func(branch string) (*github.PullRequest, error) { + n, ok := prFor[branch] + if !ok { + return nil, nil + } + return &github.PullRequest{ + Number: n, + State: "OPEN", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + HeadRefName: branch, + }, nil + } +} + +// runSyncWithGitHub executes sync against tmpDir using the supplied git and +// GitHub mocks and returns the captured stderr output. +func runSyncWithGitHub(t *testing.T, gitMock *git.MockOps, ghMock *github.MockClient) string { + t.Helper() + restore := git.SetOps(gitMock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = ghMock + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + require.NoError(t, cmd.Execute()) + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + return string(errOut) +} + +// TestSync_CreatesRemoteStackWhenPRsExist verifies the core fix: when the +// branches already have open PRs but no stack exists on GitHub, sync creates the +// stack object and reports "Stack synced". +func TestSync_CreatesRemoteStackWhenPRsExist(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var createdWith []int + var listCalls int + ghMock := &github.MockClient{ + FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102}), + ListStacksFn: func() ([]github.RemoteStack, error) { + listCalls++ + return nil, nil + }, + CreateStackFn: func(prNumbers []int) (int, error) { + createdWith = prNumbers + return 7, nil + }, + UpdateStackFn: func(string, []int) error { + t.Fatal("UpdateStack should not be called when no remote stack exists") + return nil + }, + } + + output := runSyncWithGitHub(t, newSyncMockNoRebase(tmpDir, "b1"), ghMock) + + assert.Equal(t, []int{101, 102}, createdWith, "should create the stack from both PR numbers") + assert.Equal(t, 1, listCalls, "should issue exactly one ListStacks on the create path (no redundant round-trip)") + assert.Contains(t, output, "Stack created on GitHub with 2 PRs") + assert.Contains(t, output, "Stack synced") + assert.NotContains(t, output, "Branches synced") + + // The new remote stack ID must be persisted to the stack file. + sf, err := stack.Load(tmpDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, "7", sf.Stacks[0].ID) +} + +// TestSync_AdoptsExistingEqualRemoteStack verifies that when a remote stack +// already lists exactly the local PRs, sync records its ID without issuing a +// redundant create/update and still reports "Stack synced". +func TestSync_AdoptsExistingEqualRemoteStack(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + ghMock := &github.MockClient{ + FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102}), + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102}}}, nil + }, + CreateStackFn: func([]int) (int, error) { + t.Fatal("CreateStack should not be called when the remote stack matches") + return 0, nil + }, + UpdateStackFn: func(string, []int) error { + t.Fatal("UpdateStack should not be called when the remote stack matches") + return nil + }, + } + + output := runSyncWithGitHub(t, newSyncMockNoRebase(tmpDir, "b1"), ghMock) + + assert.Contains(t, output, "already up to date") + assert.Contains(t, output, "Stack synced") + assert.NotContains(t, output, "Branches synced") + + sf, err := stack.Load(tmpDir) + require.NoError(t, err) + assert.Equal(t, "9", sf.Stacks[0].ID, "should record the adopted stack ID") +} + +// TestSync_UpdatesPartialRemoteStack verifies that when a remote stack contains +// only some of the local PRs, sync updates it with the full list. +func TestSync_UpdatesPartialRemoteStack(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}}, + } + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var updatedID string + var updatedWith []int + ghMock := &github.MockClient{ + FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102, "b3": 103}), + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102}}}, nil + }, + CreateStackFn: func([]int) (int, error) { + t.Fatal("CreateStack should not be called when a matching stack exists") + return 0, nil + }, + UpdateStackFn: func(stackID string, prNumbers []int) error { + updatedID = stackID + updatedWith = prNumbers + return nil + }, + } + + output := runSyncWithGitHub(t, newSyncMockNoRebase(tmpDir, "b1"), ghMock) + + assert.Equal(t, "9", updatedID) + assert.Equal(t, []int{101, 102, 103}, updatedWith) + assert.Contains(t, output, "Stack updated on GitHub with 3 PRs") + assert.Contains(t, output, "Stack synced") + assert.NotContains(t, output, "Branches synced") + + sf, err := stack.Load(tmpDir) + require.NoError(t, err) + assert.Equal(t, "9", sf.Stacks[0].ID) +} + +// TestSync_FewerThanTwoPRs_BranchesSynced verifies that with only one open PR +// (no stack is possible), sync skips all stack API calls and reports +// "Branches synced" rather than "Stack synced". +func TestSync_FewerThanTwoPRs_BranchesSynced(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var listCalled, createCalled bool + ghMock := &github.MockClient{ + FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101}), // b2 has no PR + ListStacksFn: func() ([]github.RemoteStack, error) { + listCalled = true + return nil, nil + }, + CreateStackFn: func([]int) (int, error) { + createCalled = true + return 0, nil + }, + } + + output := runSyncWithGitHub(t, newSyncMockNoRebase(tmpDir, "b1"), ghMock) + + assert.False(t, listCalled, "ListStacks should not be called with fewer than two PRs") + assert.False(t, createCalled, "CreateStack should not be called with fewer than two PRs") + assert.Contains(t, output, "Branches synced") + assert.NotContains(t, output, "Stack synced") +} + +// TestSync_StacksUnavailable_BranchesSynced verifies that when the stacks API +// is unavailable (404 on create), sync warns and reports "Branches synced". +func TestSync_StacksUnavailable_BranchesSynced(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + ghMock := &github.MockClient{ + FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102}), + ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, + CreateStackFn: func([]int) (int, error) { + return 0, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + } + + output := runSyncWithGitHub(t, newSyncMockNoRebase(tmpDir, "b1"), ghMock) + + assert.Contains(t, output, "Branches synced") + assert.NotContains(t, output, "Stack synced") +} + +// TestSync_PRsSpanMultipleStacks_BranchesSynced verifies that when the local +// PRs belong to more than one remote stack, sync refuses to auto-resolve the +// divergence and reports "Branches synced". +func TestSync_PRsSpanMultipleStacks_BranchesSynced(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var createCalled, updateCalled bool + ghMock := &github.MockClient{ + FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102}), + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 9, PullRequests: []int{101}}, + {ID: 10, PullRequests: []int{102}}, + }, nil + }, + CreateStackFn: func([]int) (int, error) { createCalled = true; return 0, nil }, + UpdateStackFn: func(string, []int) error { updateCalled = true; return nil }, + } + + output := runSyncWithGitHub(t, newSyncMockNoRebase(tmpDir, "b1"), ghMock) + + assert.False(t, createCalled, "CreateStack should not be called on divergence") + assert.False(t, updateCalled, "UpdateStack should not be called on divergence") + assert.Contains(t, output, "multiple stacks") + assert.NotContains(t, output, "submitting", "divergence guidance should be command-neutral, not submit-specific") + assert.Contains(t, output, "Branches synced") + assert.NotContains(t, output, "Stack synced") +} diff --git a/docs/src/content/docs/guides/stacked-prs.md b/docs/src/content/docs/guides/stacked-prs.md index 81e8eac..23fe269 100644 --- a/docs/src/content/docs/guides/stacked-prs.md +++ b/docs/src/content/docs/guides/stacked-prs.md @@ -51,4 +51,4 @@ gh stack sync - **`gh stack push`** pushes branches only (uses `--force-with-lease` for safety). It does not create or update PRs. - **`gh stack submit`** pushes branches and creates or updates PRs, linking them as a Stack on GitHub. -- **`gh stack sync`** is the all-in-one command: fetch, rebase, push, sync PR state, and optionally prune local branches for merged PRs. +- **`gh stack sync`** is the all-in-one command: fetch, rebase, push, sync PR state, link open PRs into a Stack on GitHub, and optionally prune local branches for merged PRs. diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index 6f4c0b2..65cd0be 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -134,7 +134,8 @@ This command: 3. Rebases all remaining stack branches onto the updated trunk 4. Pushes the updated branches 5. Syncs PR state from GitHub -6. Prompts to prune local branches for merged PRs (use `--prune` to prune automatically) +6. Links the open PRs into a Stack on GitHub (creating or updating the remote stack when two or more PRs exist) +7. Prompts to prune local branches for merged PRs (use `--prune` to prune automatically) If a conflict is detected during the rebase, all branches are restored to their original state, and you're advised to run `gh stack rebase` to resolve conflicts interactively. diff --git a/docs/src/content/docs/introduction/overview.md b/docs/src/content/docs/introduction/overview.md index 46a70e5..f3cb83a 100644 --- a/docs/src/content/docs/introduction/overview.md +++ b/docs/src/content/docs/introduction/overview.md @@ -88,7 +88,7 @@ While the PR UI provides the review and merge experience, the `gh stack` CLI han - **Pushing branches** — `gh stack push` pushes all branches to the remote. - **Creating PRs** — `gh stack submit` pushes branches and creates or updates PRs, linking them as a Stack on GitHub. - **Navigating the stack** — `gh stack up`, `down`, `top`, and `bottom` let you move between layers without remembering branch names. -- **Syncing everything** — `gh stack sync` fetches, rebases, pushes, and updates PR state in one command. +- **Syncing everything** — `gh stack sync` fetches, rebases, pushes, updates PR state, and links open PRs into a Stack on GitHub in one command. - **Restructuring stacks** — `gh stack modify` opens an interactive terminal UI to drop, fold, insert, rename, and reorder branches in a stack. - **Tearing down stacks** — `gh stack unstack` removes a stack from GitHub and local tracking. - **Checking out a stack** — `gh stack checkout ` pulls down a stack, with all its branches, from GitHub to your local machine. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index bd21671..3cd99de 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -309,7 +309,8 @@ Performs a safe, non-interactive synchronization of the entire stack: 3. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state, and you are advised to run `gh stack rebase` to resolve conflicts interactively. 4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred). 5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR. -6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically. +6. **Sync the stack** — links the stack's open PRs into a stack on GitHub, creating the remote stack object if it doesn't exist yet or updating it if it's partially formed. This only happens when two or more PRs exist; sync never opens PRs (use `gh stack submit` for that). +7. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically. | Flag | Description | |------|-------------| diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 465d79b..ca528ea 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -7,7 +7,7 @@ description: > branch chains, or incremental code review workflows. metadata: author: github - version: "0.0.6" + version: "0.0.7" --- # gh-stack @@ -632,7 +632,8 @@ gh stack sync [flags] 3. **Cascade rebase** all stack branches onto their updated parents (only if trunk moved). Handles merged PRs automatically. If a conflict is detected, **all branches are restored** to their pre-rebase state and the command exits with code 3 — see [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) for the resolution workflow 4. **Push** all active branches atomically 5. **Sync PR state** from GitHub and report the status of each PR -6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to skip the prompt. In non-interactive environments, pruning only happens when `--prune` is passed explicitly +6. **Sync the stack object** — link the open PRs into a stack on GitHub. If the PRs are not yet in a stack, a new stack is created; if some PRs are already in a stack, it is updated (additive only). This only happens when two or more PRs exist. Sync **never opens PRs** — use `gh stack submit` for that +7. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to skip the prompt. In non-interactive environments, pruning only happens when `--prune` is passed explicitly **Output (stderr):** @@ -642,8 +643,9 @@ gh stack sync [flags] - `✓ Pushed N branches` - `✓ PR #N () — Open` per branch - `Merged: #N, #M` for merged branches +- `✓ Stack created on GitHub with N PRs` / `✓ Stack updated on GitHub with N PRs` / `✓ Linked to the existing stack on GitHub` (when two or more PRs exist) - `✓ Pruned (merged)` per pruned branch (when pruning) -- `✓ Stack synced` +- `✓ Stack synced` when the stack object on GitHub was created/updated to match local, or `✓ Branches synced` when only the branches were synced (fewer than two PRs, stacked PRs unavailable, or a divergence) ---